diff --git a/.agents/skills/ade-autoresearch/SKILL.md b/.agents/skills/ade-autoresearch/SKILL.md index ccae8ba12..96d04aaf2 100644 --- a/.agents/skills/ade-autoresearch/SKILL.md +++ b/.agents/skills/ade-autoresearch/SKILL.md @@ -20,7 +20,7 @@ A Karpathy-style autoresearch loop for ADE perf. You (the agent) ARE the loop ru ## Inputs -- ``: the tab to optimize. Must be one of: `boot`, `lanes`, `missions`, `prs`, `work`, `files`, `run`, `graph`, `review`, `history`, `automations`, `cto`, `settings`. (`boot` = cold launch + welcome + project open + remote runtime + iOS pairing — the "main ADE screen" surface above any specific tab.) +- ``: the tab to optimize. Must be one of: `boot`, `lanes`, `prs`, `work`, `files`, `run`, `graph`, `review`, `history`, `automations`, `cto`, `settings`. (`boot` = cold launch + welcome + project open + remote runtime + iOS pairing — the "main ADE screen" surface above any specific tab.) - ``: throwaway git repo path. Defaults to `/Users/admin/Projects/perf pass` (note the space — quote it). Must exist, must be a git repo, must have a `perf-pass-seed` tag (or you create one on first run). Override via `ADE_PERF_PASS_DIR` env var. ## Real UI audit is the primary loop diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index 57320dd16..9e3c5b769 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -178,13 +178,13 @@ docs/ ├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only) └── features/ ├── agents/ ├── memory/ - ├── automations/ ├── missions/ - ├── chat/ ├── onboarding-and-settings/ - ├── computer-use/ ├── project-home/ - ├── conflicts/ ├── pull-requests/ - ├── context-packs/ ├── sync-and-multi-device/ - ├── cto/ ├── terminals-and-sessions/ - ├── files-and-editor/ └── workspace-graph/ + ├── automations/ ├── onboarding-and-settings/ + ├── chat/ ├── project-home/ + ├── computer-use/ ├── pull-requests/ + ├── conflicts/ ├── sync-and-multi-device/ + ├── context-packs/ ├── terminals-and-sessions/ + ├── cto/ ├── workspace-graph/ + ├── files-and-editor/ ├── history/ ├── lanes/ └── linear-integration/ @@ -209,7 +209,6 @@ Step 2: Map changed source to internal docs | Source Directory | Doc Location | |----------------------------------------------------|----------------------------------------------------| -| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | | apps/desktop/src/main/services/projects/ | docs/features/project-home/ | | apps/desktop/src/main/services/proof/ | docs/features/proof.md | | apps/desktop/src/main/services/review/ | docs/features/pull-requests/ | @@ -347,7 +346,7 @@ Step 2: Identify CLI-relevant desktop changes Treat anything under these paths as a candidate for new / changed / removed CLI surface: - apps/desktop/src/main/services/** (each service is a candidate action - domain — lanes, prs, chat, tests, proof, run, git, files, missions, + domain — lanes, prs, chat, tests, proof, run, git, files, automations, computerUse, context, conflicts, history, memory, onboarding, pty, sessions, processes, sync, config, cto, ai) - apps/desktop/src/preload/** and apps/desktop/src/shared/** (IPC and @@ -371,7 +370,7 @@ Step 3: Map each candidate to the CLI Step 4: Apply auto-fix edits — scoped to apps/ade-cli/ only - New feature: add a typed subcommand if the desktop feature is a distinct - user-facing workflow (lane / PR / chat / test / run / proof / mission / + user-facing workflow (lane / PR / chat / test / run / proof / automation / etc.). If it is just a new low-level service action, ensure it is reachable via the actions registry and skip a typed wrapper. - Renamed or behavior-changed feature: update the existing case to match @@ -496,16 +495,12 @@ After all seven passes: - One project: `cd apps/desktop && npx vitest run --project unit-main` - Sharded (CI uses 8): `cd apps/desktop && npx vitest run --shard=1/8` -**MCP server** (`apps/mcp-server/`): -- `cd apps/mcp-server && npx vitest run ` or `npm test` - **Web** (`apps/web/`) — marketing site, no tests. ### Feature docs (read for context before adding tests) | Changed source area | Feature doc | |---|---| -| services/orchestrator/, renderer missions/ | docs/features/missions/ | | services/prs/, renderer prs/ | docs/features/pull-requests/ | | services/lanes/, renderer lanes/ | docs/features/lanes/ | | services/chat/, services/ai/, renderer chat/ | docs/features/chat/ + docs/features/agents/ | diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index 53e9f4cdd..114a37b49 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -35,7 +35,7 @@ Outputs are exactly two things: the Phase 4 summary, and fatal-error messages (t ## Guardrails (read once, apply everywhere) -- Do NOT touch the public Mintlify site: `docs.json` and any root-level `*.mdx`, plus the root-level dirs `chat/`, `tools/`, `missions/`, `changelog/`, `configuration/`, `computer-use/`, `context-packs/`, `getting-started/`, `guides/`, `automations/`, `lanes/`, `cto/`. Internal docs under `docs/` are in scope. +- Do NOT touch the public Mintlify site: `docs.json` and any root-level `*.mdx`, plus the root-level dirs `chat/`, `tools/`, `changelog/`, `configuration/`, `computer-use/`, `context-packs/`, `getting-started/`, `guides/`, `automations/`, `lanes/`, `cto/`. Internal docs under `docs/` are in scope. - Do NOT modify `docs/OPTIMIZATION_OPPORTUNITIES.md` — append-only, human-curated. - Do NOT run `apps/mcp-server` checks; the MCP server was removed. The agent surface is `apps/ade-cli`. - Do NOT skip the sharded test run or substitute project-subset runs for it. `/finalize` is the gate that runs the full suite. diff --git a/.claude/commands/optimize.md b/.claude/commands/optimize.md index 8b5a85059..27e71f2fd 100644 --- a/.claude/commands/optimize.md +++ b/.claude/commands/optimize.md @@ -35,7 +35,7 @@ Do not make speculative cleanup the main result. If you cannot reproduce a suspe - `AGENTS.md` or the prompt-provided project instructions. - `docs/ARCHITECTURE.md` if the change crosses app/service boundaries. - Feature docs under `docs/features/**` that match `$ARGUMENTS`. - - Existing playbooks if the surface involves PRs, lanes, missions, computer use, sync, or release. + - Existing playbooks if the surface involves PRs, lanes, computer use, sync, or release. 2. Inspect the changed surface: - `git status --short` @@ -143,7 +143,7 @@ Run realistic flows with logs open. - Review - History - Automations - - Missions + - CTO - Settings If `$ARGUMENTS` names a surface, spend most time there but still check adjacent routes that stay mounted or subscribe to the same data. diff --git a/AGENTS.md b/AGENTS.md index 050dc37c0..3ee87c62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## About this project -- ADE is a local-first desktop application for orchestrating coding agents, missions, lanes, PR workflows, and proof/artifact capture. +- ADE is a local-first desktop application for orchestrating coding agents, lanes, PR workflows, and proof/artifact capture. - The main product lives in `apps/desktop` and is built with Electron, React, and TypeScript. - The ADE CLI lives in `apps/ade-cli` and shares core services with the desktop app. - State is primarily stored under `.ade/` inside the active project, with runtime metadata in SQLite and machine-local files under `.ade/secrets`, `.ade/cache`, and `.ade/artifacts`. @@ -36,7 +36,6 @@ ## Terminology - Use "lane" for ADE worktrees/branches. -- Use "mission" for orchestrated multi-step work. - Use "computer use" for screenshot/video/GUI/browser proof flows. ## Style preferences diff --git a/README.md b/README.md index 33298fbc7..64c0faee6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Every worktree, every agent, every PR — synced to iOS. Start a task on macOS, -Plus files, terminals, git history, workspace graph, multi-tasking, Linear sync, long-running missions, cron automations, computer-use proofs, and the `ade` CLI. +Plus files, terminals, git history, workspace graph, multi-tasking, Linear sync, cron automations, computer-use proofs, and the `ade` CLI. ## Install @@ -215,7 +215,7 @@ Validate with `npm --prefix apps/desktop run typecheck` and `npm run test:deskto ## Links -[Quickstart](https://www.ade-app.dev/docs/quickstart) · [Key concepts](https://www.ade-app.dev/docs/key-concepts) · [Worktrees](https://www.ade-app.dev/docs/lanes/overview) · [Missions](https://www.ade-app.dev/docs/missions/overview) · [Computer use](https://www.ade-app.dev/docs/computer-use/overview) · [Changelog](https://www.ade-app.dev/docs/changelog/v1.1.0) · [Contributing](CONTRIBUTING.md) +[Quickstart](https://www.ade-app.dev/docs/quickstart) · [Key concepts](https://www.ade-app.dev/docs/key-concepts) · [Worktrees](https://www.ade-app.dev/docs/lanes/overview) · [Computer use](https://www.ade-app.dev/docs/computer-use/overview) · [Changelog](https://www.ade-app.dev/docs/changelog/v1.1.0) · [Contributing](CONTRIBUTING.md) ## License diff --git a/ai-tools/claude-code.mdx b/ai-tools/claude-code.mdx index f32fc472f..ea5fd57f8 100644 --- a/ai-tools/claude-code.mdx +++ b/ai-tools/claude-code.mdx @@ -1,6 +1,6 @@ --- title: "Claude Code" -description: "Use Claude Code as a CLI coding agent inside ADE lanes — launch sessions, connect ADE CLI tools, configure permissions, and track work across agent chat, missions, and CTO delegations." +description: "Use Claude Code as a CLI coding agent inside ADE lanes — launch sessions, connect ADE CLI tools, configure permissions, and track work across agent chat, worker runs, and CTO delegations." icon: "terminal" --- @@ -8,7 +8,7 @@ icon: "terminal" ADE integrates with [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's CLI agent for coding tasks. When Claude Code is installed on your machine, ADE can launch it as a managed session inside any lane. -Claude Code is one of ADE's deepest integrations — it serves as both a chat provider (for Agent Chat) and an execution backend (for missions, CTO workers, and automations). ADE manages the Claude Code process lifecycle, exposes the `ade` CLI so the agent can reach ADE's lane, git, and PR tools, forwards environment variables, and tracks every session in the History view. +Claude Code is one of ADE's deepest integrations — it serves as both a chat provider (for Agent Chat) and an execution backend (for worker runs, CTO workers, and automations). ADE manages the Claude Code process lifecycle, exposes the `ade` CLI so the agent can reach ADE's lane, git, and PR tools, forwards environment variables, and tracks every session in the History view. --- @@ -32,7 +32,7 @@ Claude Code sessions are used in several places: | Surface | How Claude Code is used | |---------|------------------------| | **Agent Chat** | Select Claude as the provider in the model selector. ADE starts a Claude Code session with the `ade` CLI on `PATH`. | -| **Missions** | Used as a worker executor (`claude_cli`) during mission execution phases. Each worker gets its own Claude Code process. | +| **Workers** | Used as a worker executor (`claude_cli`) during worker run execution phases. Each worker gets its own Claude Code process. | | **CTO Workers** | When the CTO delegates a task to an employee agent, it can use Claude Code as the execution backend. | | **Automations** | Automation rules with `agent-session` execution kind can use Claude Code as the underlying agent. | @@ -77,7 +77,7 @@ ADE supports several permission modes for Claude Code sessions: | `acceptEdits` | Auto-accept file edits — the agent can write files without asking, but still asks for terminal commands | | `bypassPermissions` | Skip all permission prompts — full autonomy. Use with caution. | -Configure the default permission mode in **Settings > AI Providers** or per-mission in the mission creation dialog. For CTO-delegated workers, the CTO sets the permission mode based on the task's risk level. +Configure the default permission mode in **Settings > AI Providers** or per-worker run in the worker run creation dialog. For CTO-delegated workers, the CTO sets the permission mode based on the task's risk level. --- diff --git a/ai-tools/cursor.mdx b/ai-tools/cursor.mdx index 98354a6f4..0f50c577e 100644 --- a/ai-tools/cursor.mdx +++ b/ai-tools/cursor.mdx @@ -54,7 +54,7 @@ In any Agent Chat session, use the **model selector** in the chat header to choo ADE queries Cursor for its available models at startup and caches the result. The discovered models appear in: - The chat model selector -- The mission model configuration +- The worker run model configuration - The unified model selector throughout the app ### Session behavior diff --git a/ai-tools/windsurf.mdx b/ai-tools/windsurf.mdx index 1642a3205..55c98bddb 100644 --- a/ai-tools/windsurf.mdx +++ b/ai-tools/windsurf.mdx @@ -6,7 +6,7 @@ icon: "water" ## OpenCode Integration -[OpenCode](https://opencode.ai) runs as a local server process and exposes an OpenAI-compatible endpoint. ADE manages the OpenCode process lifecycle — starting, stopping, and monitoring it — and routes chat and mission requests through it like any other provider. +[OpenCode](https://opencode.ai) runs as a local server process and exposes an OpenAI-compatible endpoint. ADE manages the OpenCode process lifecycle — starting, stopping, and monitoring it — and routes chat and worker run requests through it like any other provider. ### How it works diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 2972c9e87..ecf4d5f09 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -29,35 +29,7 @@ function createRuntime() { const operationFinish = vi.fn(); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-test-")); fs.mkdirSync(path.join(projectRoot, ".ade", "orchestrator"), { recursive: true }); - const teamMembers: Array> = []; - const threadRows: Array> = []; - const threadMessages = new Map>>(); - let messageCounter = 0; const kv = new Map(); - - const ensureThread = (input: { missionId: string; attemptId: string; runId?: string | null }): Record => { - const existing = threadRows.find( - (thread) => thread.missionId === input.missionId && thread.attemptId === input.attemptId - ); - if (existing) return existing; - const thread = { - id: `thread-${input.attemptId}`, - missionId: input.missionId, - threadType: "worker", - runId: input.runId ?? "run-1", - attemptId: input.attemptId - }; - threadRows.push(thread); - threadMessages.set(thread.id, []); - return thread; - }; - - const appendThreadMessage = (threadId: string, entry: Record): void => { - const existing = threadMessages.get(threadId) ?? []; - existing.push(entry); - threadMessages.set(threadId, existing); - }; - const laneRows = [ { id: "lane-1", @@ -111,50 +83,9 @@ function createRuntime() { } kv.set(key, value); }), - get: vi.fn((sql: string) => { - if (sql.includes("orchestrator_evaluations") && sql.includes("SELECT")) { - return { - id: "eval-1", run_id: "run-1", mission_id: "mission-1", evaluator_id: "evaluator-1", - scores_json: '{"planQuality":8}', issues_json: '[]', summary: "Good run", - improvements_json: '[]', metadata_json: '{}', evaluated_at: new Date().toISOString() - }; - } - return { count: 0 }; - }), - all: vi.fn((sql: string) => { - if (sql.includes("from missions")) return [{ id: "mission-1" }]; - if (sql.includes("orchestrator_evaluations")) return [{ - id: "eval-1", run_id: "run-1", mission_id: "mission-1", evaluator_id: "evaluator-1", - scores_json: '{"planQuality":8}', issues_json: '[]', summary: "Good run", - improvements_json: null, metadata_json: null, evaluated_at: new Date().toISOString() - }]; - return []; - }), - run: vi.fn((sql: string, params?: unknown[]) => { - if ( - sql.toLowerCase().includes("insert into orchestrator_team_members") - && Array.isArray(params) - && params.length >= 12 - ) { - const metadataRaw = params[9]; - const metadata = typeof metadataRaw === "string" && metadataRaw.length > 0 - ? JSON.parse(metadataRaw) - : {}; - teamMembers.push({ - id: params[0], - runId: params[1], - missionId: params[2], - provider: params[3], - model: params[4], - role: params[5], - sessionId: params[6], - status: params[7], - source: typeof metadata.source === "string" ? metadata.source : "claude-native", - parentWorkerId: typeof metadata.parentWorkerId === "string" ? metadata.parentWorkerId : null, - metadata - }); - } - }) + get: vi.fn(() => ({ count: 0 })), + all: vi.fn(() => []), + run: vi.fn() }, keybindingsService: { get: vi.fn(() => [{ command: "ade.openCommandPalette", binding: "mod+k" }]), @@ -304,26 +235,6 @@ function createRuntime() { diffService: { getChanges: vi.fn(async () => ({ unstaged: [], staged: [] })) }, - missionService: { - addIntervention: vi.fn(({ missionId, title, body }: { missionId: string; title: string; body: string }) => ({ - id: "intervention-1", - missionId, - status: "open", - title, - body - })), - get: vi.fn((missionId: string) => ({ - id: missionId, - prompt: "test mission", - status: "running", - laneId: "lane-1", - interventions: [] - })), - create: vi.fn(({ prompt }: any) => ({ id: "mission-new", prompt, status: "planned" })), - resolveIntervention: vi.fn(({ missionId, interventionId, status }: any) => ({ - id: interventionId, missionId, status - })) - }, ptyService: { create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })), sendToSession: vi.fn(async () => ({ @@ -846,181 +757,6 @@ function createRuntime() { }), typeText: vi.fn(async ({ laneId, text }: { laneId: string; text: string }) => ({ ok: true, laneId, textLength: text.length })), } as any, - orchestratorService: { - listRuns: vi.fn(() => []), - pauseRun: vi.fn(({ runId }: any) => ({ id: runId, status: "paused" })), - resumeRun: vi.fn(({ runId }: any) => ({ id: runId, status: "running" })), - getRunGraph: vi.fn(({ runId }: any) => ({ - run: { id: runId, missionId: "mission-1", status: "running" }, - steps: [{ id: "step-1", stepKey: "step-a", laneId: "lane-1", status: "completed" }], - attempts: [{ id: "attempt-1", stepId: "step-1", status: "completed" }], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [{ id: "tl-1", runId, eventType: "step_started", reason: "started" }], - runtimeEvents: [], - completionEvaluation: { complete: true } - })), - listTimeline: vi.fn(({ runId }: any) => [ - { id: "tl-1", runId, stepId: null, eventType: "run_started", reason: "started" }, - { id: "tl-2", runId, stepId: "step-1", eventType: "step_started", reason: "started" } - ]), - listAttempts: vi.fn(() => []), - addSteps: vi.fn(({ steps }: { steps: Array> }) => - steps.map((step, index) => ({ - id: `step-created-${index + 1}`, - runId: "run-1", - missionStepId: null, - stepKey: String(step.stepKey ?? `step-created-${index + 1}`), - stepIndex: Number(step.stepIndex ?? index), - title: String(step.title ?? "Created step"), - laneId: typeof step.laneId === "string" ? step.laneId : null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: null, - completedAt: null, - metadata: step.metadata ?? {} - })) - ), - createHandoff: vi.fn(), - startReadyAutopilotAttempts: vi.fn(async () => 0), - skipStep: vi.fn(), - completeAttempt: vi.fn(), - updateStepMetadata: vi.fn(), - supersedeStep: vi.fn(), - updateStepDependencies: vi.fn(), - appendRuntimeEvent: vi.fn(), - appendTimelineEvent: vi.fn(), - emitRuntimeUpdate: vi.fn(), - listRetrospectives: vi.fn(() => [ - { - id: "retro:run-1", - missionId: "mission-1", - runId: "run-1", - generatedAt: new Date().toISOString(), - schemaVersion: 1, - finalStatus: "succeeded", - wins: [], - failures: [], - unresolvedRisks: [], - followUpActions: [], - topPainPoints: [], - topImprovements: [], - patternsToCapture: [], - estimatedImpact: "n/a", - changelog: [] - } - ]), - listRetrospectiveTrends: vi.fn(() => [ - { - id: "trend-1", - projectId: "project-1", - missionId: "mission-1", - runId: "run-1", - retrospectiveId: "retro:run-1", - sourceMissionId: "mission-0", - sourceRunId: "run-0", - sourceRetrospectiveId: "retro:run-0", - painPointKey: "slow-tests", - painPointLabel: "Slow tests", - status: "still_open", - previousPainScore: 2, - currentPainScore: 2, - createdAt: new Date().toISOString() - } - ]), - listRetrospectivePatternStats: vi.fn(() => [ - { - id: "pattern-stat-1", - projectId: "project-1", - patternKey: "slow-tests", - patternLabel: "Slow tests", - occurrenceCount: 2, - firstSeenRetrospectiveId: "retro:run-0", - firstSeenRunId: "run-0", - lastSeenRetrospectiveId: "retro:run-1", - lastSeenRunId: "run-1", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - ]), - addReflection: vi.fn((input: any) => ({ - id: "reflection-1", - projectId: "project-1", - missionId: input.missionId, - runId: input.runId, - stepId: input.stepId ?? null, - attemptId: input.attemptId ?? null, - agentRole: input.agentRole, - phase: input.phase, - signalType: input.signalType, - observation: input.observation, - recommendation: input.recommendation ?? "", - context: input.context ?? "", - occurredAt: new Date().toISOString(), - createdAt: new Date().toISOString(), - schemaVersion: 1 - })) - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn(async ({ missionId }: any) => ({ - started: { run: { id: "run-1", missionId, status: "running" }, steps: [] }, - mission: { id: missionId } - })), - finalizeRun: vi.fn(() => ({ finalized: true, blockers: [], finalStatus: "succeeded" })), - cancelRunGracefully: vi.fn(async ({ runId }: any) => ({ cancelled: true, runId })), - resumeRun: vi.fn(async ({ runId }: any) => ({ id: runId, status: "running" })), - steerMission: vi.fn(({ missionId }: any) => ({ acknowledged: true, appliedAt: new Date().toISOString() })), - getWorkerStates: vi.fn(({ runId }: any) => [ - { attemptId: "a-1", stepId: "s-1", runId, state: "running" } - ]), - getMissionMetrics: vi.fn(({ missionId }: any) => ({ missionId, samples: [] })), - getTeamMembers: vi.fn(() => teamMembers), - listChatThreads: vi.fn(({ missionId }: any) => - threadRows.filter((thread) => thread.missionId === missionId) - ), - getThreadMessages: vi.fn(({ threadId, limit }: any) => { - const entries = threadMessages.get(String(threadId)) ?? []; - const max = typeof limit === "number" ? Math.max(1, Math.floor(limit)) : entries.length; - return entries.slice(-max); - }), - sendAgentMessage: vi.fn(({ missionId, fromAttemptId, toAttemptId, content, metadata }: any) => { - const sourceThread = ensureThread({ missionId, attemptId: String(fromAttemptId), runId: "run-1" }); - const targetThread = ensureThread({ missionId, attemptId: String(toAttemptId), runId: "run-1" }); - const timestamp = new Date().toISOString(); - const sourceEntry = { - id: `msg-${++messageCounter}`, - role: "agent", - content, - timestamp, - threadId: sourceThread.id, - attemptId: fromAttemptId, - target: { targetAttemptId: toAttemptId }, - metadata: metadata ?? null - }; - const deliveryEntry = { - id: `msg-${++messageCounter}`, - role: "agent", - content, - timestamp, - threadId: targetThread.id, - attemptId: fromAttemptId, - target: { targetAttemptId: toAttemptId }, - metadata: { ...(metadata ?? {}), interAgentDelivery: true } - }; - appendThreadMessage(String(sourceThread.id), sourceEntry); - appendThreadMessage(String(targetThread.id), deliveryEntry); - return sourceEntry; - }), - dispose: vi.fn() - } as any, eventBuffer: { push: vi.fn(), drain: vi.fn((cursor: number, limit?: number) => ({ @@ -1050,8 +786,7 @@ async function initialize(handler: ReturnType || requestedRole === "external" || requestedRole === "evaluator"; const previousRole = process.env.ADE_DEFAULT_ROLE; - const shouldInjectRole = previousRole == null && validRole; - if (shouldInjectRole && requestedRole) { + if (validRole && requestedRole) { process.env.ADE_DEFAULT_ROLE = requestedRole; } try { @@ -1062,8 +797,12 @@ async function initialize(handler: ReturnType params: identity ? { identity } : {} }); } finally { - if (shouldInjectRole) { - delete process.env.ADE_DEFAULT_ROLE; + if (validRole) { + if (previousRole != null) { + process.env.ADE_DEFAULT_ROLE = previousRole; + } else { + delete process.env.ADE_DEFAULT_ROLE; + } } } } @@ -1415,7 +1154,6 @@ describe("adeRpcServer", () => { const names = (result.actions ?? []).map((tool: any) => tool.name); expect(names).not.toContain("spawn_worker"); - expect(names).not.toContain("read_mission_status"); expect(names).not.toContain("get_cto_state"); expect(names).not.toContain("get_environment_info"); expect(names).not.toContain("launch_app"); @@ -1460,13 +1198,13 @@ describe("adeRpcServer", () => { const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; const names = (result.actions ?? []).map((tool: any) => tool.name); - expect(names).toContain("delegate_to_subagent"); + expect(names).toContain("spawn_agent"); expect(names).not.toContain("get_cto_state"); expect(names).not.toContain("getLinearSyncDashboard"); }); }); - it("lists the full tool surface including coordinator orchestration tools for orchestrator callers", async () => { + it("lists the orchestration-safe tool surface for orchestrator callers", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); @@ -1481,7 +1219,6 @@ describe("adeRpcServer", () => { "check_conflicts", "merge_lane", "ask_user", - "reflection_add", "get_environment_info", "launch_app", "interact_gui", @@ -1493,99 +1230,13 @@ describe("adeRpcServer", () => { "get_lane_status", "list_lanes", "commit_changes", - "create_mission", - "start_mission", - "pause_mission", - "resume_mission", - "cancel_mission", - "steer_mission", - "resolve_intervention", - "get_mission", - "get_run_graph", "stream_events", - "get_step_output", - "get_worker_states", - "get_timeline", - "list_retrospectives", - "list_reflection_trends", - "list_reflection_pattern_stats", - "get_mission_metrics", - "get_final_diff", - "evaluate_run", - "list_evaluations", - "get_evaluation_report", - "spawn_worker", - "delegate_parallel", - "read_mission_status", - "revise_plan", - "retry_step", - "skip_step", - "message_worker", - "report_status", - "report_result", - "report_validation", - "update_tool_profiles", - "transfer_lane", - "request_specialist", - "read_file", - "search_files", - "get_project_context" ]) ); - expect(names.length).toBeGreaterThan(38); + expect(names).not.toContain("reflection_add"); + expect(names.length).toBeGreaterThan(20); }); - it("shows agent-safe delegation, reporting, and observation coordinator tools to agent callers", async () => { - const { runtime } = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1" - }); - - const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; - const names = (result.actions ?? []).map((tool: any) => tool.name); - - expect(names).toEqual( - expect.arrayContaining([ - "commit_changes", - "rebase_lane", - "stash_push", - "list_stashes", - "stash_apply", - "stash_pop", - "stash_drop", - "stash_clear", - "report_status", - "report_result", - "report_validation", - "delegate_to_subagent", - "delegate_parallel", - "message_worker", - "get_worker_output", - "list_workers", - "read_mission_status", - "read_mission_state", - "list_tasks", - "get_budget_status", - "get_project_context", - ]) - ); - expect(names).not.toEqual( - expect.arrayContaining([ - "spawn_worker", - "revise_plan", - "request_specialist", - "set_current_phase", - "update_tool_profiles", - ]) - ); - }); it("exposes lane-tied macOS VM computer-use tools to agent callers", async () => { const fixture = createRuntime(); @@ -1594,7 +1245,6 @@ describe("adeRpcServer", () => { await initialize(handler, { callerId: "worker-1", role: "agent", - missionId: "mission-1", runId: "run-1", stepId: "step-1", attemptId: "attempt-1", @@ -1756,7 +1406,7 @@ describe("adeRpcServer", () => { }); }); - it("hides ADE spawn and mission-worker tools from standalone chat callers", async () => { + it("hides ADE spawn tools from standalone chat callers", async () => { await withEnv({ ADE_DEFAULT_ROLE: "agent", ADE_CHAT_SESSION_ID: "chat-1" }, async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); @@ -1778,7 +1428,6 @@ describe("adeRpcServer", () => { expect(names).not.toContain("report_status"); expect(names).not.toContain("report_result"); expect(names).not.toContain("get_worker_output"); - expect(names).not.toContain("read_mission_status"); expect(names).not.toContain("list_workers"); }); }); @@ -2054,62 +1703,7 @@ describe("adeRpcServer", () => { } }); - it("lets agent callers use safe mission observation coordinator tools", 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: [], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false } - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-1", - attemptId: "attempt-1" - }); - - const response = await callTool(handler, "read_mission_status", {}); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - expect(response.structuredContent.runId).toBe("run-1"); - }); - }); - - it("rejects coordinator-only tool calls from agent callers before coordinator dispatch", async () => { - await withEnv({ ADE_RUN_ID: "run-1" }, async () => { - const { runtime } = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-1", - attemptId: "attempt-1" - }); - - const response = await callTool(handler, "spawn_worker", { - name: "implementation-worker", - prompt: "Do work" - }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("Unsupported tool: spawn_worker"); - }); - }); it("rejects standalone chat calls to ADE spawn_agent", async () => { await withEnv({ ADE_DEFAULT_ROLE: "agent", ADE_CHAT_SESSION_ID: "chat-1" }, async () => { @@ -2128,279 +1722,12 @@ describe("adeRpcServer", () => { }); }); - it("lets agent callers delegate nested work only beneath their own worker", 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-1", stepKey: "step-a", laneId: "lane-1", status: "running", metadata: {} }], - attempts: [{ id: "attempt-1", stepId: "step-1", status: "running" }], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false } - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-1", - attemptId: "attempt-1" - }); - - const response = await callTool(handler, "delegate_parallel", { - tasks: [ - { name: "child-1", prompt: "Handle the first child task.", modelId: "openai/gpt-5.3-codex" }, - { name: "child-2", prompt: "Handle the second child task.", modelId: "openai/gpt-5.3-codex" }, - ] - }); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - expect(response.structuredContent.parentWorkerId).toBe("step-a"); - expect(response.structuredContent.total).toBe(2); - }); - }); - - it("rejects agent delegation attempts that target another worker", 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-1", stepKey: "step-a", laneId: "lane-1", status: "running", metadata: {} }, - { id: "step-2", stepKey: "step-b", laneId: "lane-1", status: "running", metadata: {} }, - ], - attempts: [{ id: "attempt-1", stepId: "step-1", status: "running" }], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false } - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-1", - attemptId: "attempt-1" - }); - - const response = await callTool(handler, "delegate_to_subagent", { - parentWorkerId: "step-b", - name: "rogue-child", - prompt: "Try to escape the current worker scope." - }); - - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( - "may only delegate beneath its own worker 'step-a'" - ); - }); - }); - - it("still routes coordinator-only tool calls for orchestrator callers", async () => { - const { runtime } = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); - - await initialize(handler, { callerId: "coord-1", role: "orchestrator" }); - const response = await callTool(handler, "spawn_worker", { - name: "implementation-worker", - prompt: "Do work" - }); - - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("requires run context"); - }); - - it("spawns workers for active runs when project and workspace roots differ", async () => { - await withEnv({ ADE_MISSION_ID: "mission-1", ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-runtime-workspace-")); - fixture.runtime.orchestratorService.getRunGraph = vi.fn(({ runId }: any) => ({ - run: { - id: runId, - missionId: "mission-1", - status: "running", - metadata: { - phaseOverride: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - askQuestions: { enabled: true, maxQuestions: 3 }, - validationGate: { tier: "none", required: false }, - orderingConstraints: { mustBeFirst: true }, - }, - ], - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - }, - }, - }, - }, - steps: [], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false }, - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "coord-1", - role: "orchestrator", - missionId: "mission-1", - runId: "run-from-identity", - }); - - const response = await callTool(handler, "spawn_worker", { - name: "planning-worker", - prompt: "Research the codebase and propose a plan.", - laneId: "lane-1", - }); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent).toMatchObject({ - ok: true, - name: "planning-worker", - }); - expect(fixture.runtime.projectRoot).not.toBe(fixture.runtime.workspaceRoot); - expect(JSON.stringify(response.structuredContent ?? {})).not.toContain("Run not found"); - }); - }); - - it("falls back to env orchestrator role when initialize sends an unknown role", async () => { - const fixture = createRuntime(); - const previousRole = process.env.ADE_DEFAULT_ROLE; - const previousMissionId = process.env.ADE_MISSION_ID; - const previousRunId = process.env.ADE_RUN_ID; - process.env.ADE_DEFAULT_ROLE = "orchestrator"; - process.env.ADE_MISSION_ID = "mission-1"; - process.env.ADE_RUN_ID = "run-1"; - try { - fixture.runtime.orchestratorService.getRunGraph = vi.fn(() => ({ - run: { id: "run-1", missionId: "mission-1", status: "running", metadata: {} }, - steps: [], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false } - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { callerId: "coord-1", role: "assistant" as any }); - const response = await callTool(handler, "read_mission_status", {}); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - expect(response.structuredContent.runId).toBe("run-1"); - } finally { - if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; - else process.env.ADE_DEFAULT_ROLE = previousRole; - if (previousMissionId == null) delete process.env.ADE_MISSION_ID; - else process.env.ADE_MISSION_ID = previousMissionId; - if (previousRunId == null) delete process.env.ADE_RUN_ID; - else process.env.ADE_RUN_ID = previousRunId; - } - }); - it("keeps env orchestrator role even when initialize requests agent", async () => { - const fixture = createRuntime(); - const previousRole = process.env.ADE_DEFAULT_ROLE; - const previousMissionId = process.env.ADE_MISSION_ID; - const previousRunId = process.env.ADE_RUN_ID; - process.env.ADE_DEFAULT_ROLE = "orchestrator"; - process.env.ADE_MISSION_ID = "mission-1"; - process.env.ADE_RUN_ID = "run-1"; - try { - fixture.runtime.orchestratorService.getRunGraph = vi.fn(() => ({ - run: { id: "run-1", missionId: "mission-1", status: "running", metadata: {} }, - steps: [], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false } - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "coord-1", role: "agent" as any }); - const response = await callTool(handler, "read_mission_status", {}); - expect(response.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - expect(response.structuredContent.runId).toBe("run-1"); - } finally { - if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; - else process.env.ADE_DEFAULT_ROLE = previousRole; - if (previousMissionId == null) delete process.env.ADE_MISSION_ID; - else process.env.ADE_MISSION_ID = previousMissionId; - if (previousRunId == null) delete process.env.ADE_RUN_ID; - else process.env.ADE_RUN_ID = previousRunId; - } - }); - it("does not let env agent sessions escalate to orchestrator tools", async () => { - const fixture = createRuntime(); - const previousRole = process.env.ADE_DEFAULT_ROLE; - const previousMissionId = process.env.ADE_MISSION_ID; - const previousRunId = process.env.ADE_RUN_ID; - const previousStepId = process.env.ADE_STEP_ID; - const previousAttemptId = process.env.ADE_ATTEMPT_ID; - process.env.ADE_DEFAULT_ROLE = "agent"; - process.env.ADE_MISSION_ID = "mission-1"; - process.env.ADE_RUN_ID = "run-1"; - process.env.ADE_STEP_ID = "step-1"; - process.env.ADE_ATTEMPT_ID = "attempt-1"; - try { - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "worker-1", role: "orchestrator" as any }); - const response = await callTool(handler, "spawn_worker", { - name: "rogue-worker", - prompt: "Try to escape worker scope", - }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("Unsupported tool: spawn_worker"); - } finally { - if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; - else process.env.ADE_DEFAULT_ROLE = previousRole; - if (previousMissionId == null) delete process.env.ADE_MISSION_ID; - else process.env.ADE_MISSION_ID = previousMissionId; - if (previousRunId == null) delete process.env.ADE_RUN_ID; - else process.env.ADE_RUN_ID = previousRunId; - if (previousStepId == null) delete process.env.ADE_STEP_ID; - else process.env.ADE_STEP_ID = previousStepId; - if (previousAttemptId == null) delete process.env.ADE_ATTEMPT_ID; - else process.env.ADE_ATTEMPT_ID = previousAttemptId; - } - }); it("does not advertise resources to orchestrator callers", async () => { const { runtime } = createRuntime(); @@ -2423,101 +1750,8 @@ describe("adeRpcServer", () => { } }); - it("routes reflection_add and uses initialize identity fallback", async () => { - await withEnv({ ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-1", - attemptId: "attempt-1" - }); - - const response = await callTool(handler, "reflection_add", { - signalType: "frustration", - agentRole: "implementer", - phase: "development", - observation: "Typecheck takes too long for small edits", - recommendation: "Cache incremental build artifacts", - context: "Running npm run typecheck repeatedly", - occurredAt: "2026-03-05T01:23:45.000Z" - }); - - expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.orchestratorService.addReflection).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - signalType: "frustration", - }) - ); - expect(response.structuredContent.reflection.id).toBe("reflection-1"); - }); - }); - - it("rejects reflection_add payloads missing strict fields", async () => { - await withEnv({ ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - }); - - const response = await callTool(handler, "reflection_add", { - signalType: "idea", - agentRole: "implementer", - phase: "development", - observation: "Need a faster test target", - recommendation: "Split unit and integration suites", - context: "running test command" - }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.structuredContent ?? {})).toContain("occurredAt"); - }); - }); - - it("lists retrospectives, trends, and pattern stats with caller-context fallback", async () => { - await withEnv({ ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { - callerId: "worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - }); - - const retrospectivesResponse = await callTool(handler, "list_retrospectives", {}); - expect(retrospectivesResponse?.isError).toBeUndefined(); - expect(fixture.runtime.orchestratorService.listRetrospectives).toHaveBeenCalledWith( - expect.objectContaining({ missionId: "mission-1" }) - ); - expect(Array.isArray(retrospectivesResponse.structuredContent.retrospectives)).toBe(true); - const trendsResponse = await callTool(handler, "list_reflection_trends", {}); - expect(trendsResponse?.isError).toBeUndefined(); - expect(fixture.runtime.orchestratorService.listRetrospectiveTrends).toHaveBeenCalledWith( - expect.objectContaining({ missionId: "mission-1", runId: "run-1" }) - ); - expect(Array.isArray(trendsResponse.structuredContent.trends)).toBe(true); - const patternStatsResponse = await callTool(handler, "list_reflection_pattern_stats", {}); - expect(patternStatsResponse?.isError).toBeUndefined(); - expect(fixture.runtime.orchestratorService.listRetrospectivePatternStats).toHaveBeenCalledWith( - expect.objectContaining({ limit: 100 }) - ); - expect(Array.isArray(patternStatsResponse.structuredContent.patternStats)).toBe(true); - }); - }); it("routes spawn_agent to lane-scoped tracked pty sessions", async () => { const fixture = createRuntime(); @@ -3079,777 +2313,14 @@ describe("adeRpcServer", () => { expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); }); - it("routes coordinator report_status via ADE RPC and mutates run metadata through coordinator tools", 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-worker-1", - runId: "run-1", - missionStepId: null, - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - completedAt: null, - metadata: {} - } - ], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: null - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "coord-1", role: "orchestrator", missionId: "mission-1", runId: "run-from-identity" }); - const response = await callTool(handler, "report_status", { - workerId: "worker-1", - progressPct: 45, - blockers: [], - confidence: 0.82, - nextAction: "continue implementation", - laneId: "lane-1", - details: "working through API edge cases" - }); - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - expect(fixture.runtime.orchestratorService.updateStepMetadata).toHaveBeenCalled(); - expect(fixture.runtime.orchestratorService.appendRuntimeEvent).toHaveBeenCalled(); - }); - }); - it("forwards sub-agent report_status updates to parent and emits worker_status_reported runtime events", 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: {} - }, - { - id: "step-child", - runId: "run-1", - missionStepId: null, - stepKey: "child-worker", - stepIndex: 1, - title: "Child Worker", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: "attempt-child", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - completedAt: null, - metadata: { - isSubAgent: true, - parentWorkerId: "parent-worker" - } - } - ], - attempts: [ - { id: "attempt-parent", stepId: "step-parent", status: "running", createdAt: new Date().toISOString() }, - { id: "attempt-child", stepId: "step-child", 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-child", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-child", - attemptId: "attempt-child" - }); - const response = await callTool(handler, "report_status", { - workerId: "child-worker", - progressPct: 45, - blockers: [], - confidence: 0.8, - nextAction: "Continue implementation", - laneId: "lane-1" - }); - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - expect(fixture.runtime.eventBuffer.push).toHaveBeenCalledWith( - expect.objectContaining({ - category: "runtime", - payload: expect.objectContaining({ - type: "worker_status_reported", - runId: "run-1", - reason: "report_status" - }) - }) - ); - expect(fixture.runtime.aiOrchestratorService.sendAgentMessage).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - fromAttemptId: "attempt-child", - toAttemptId: "attempt-parent", - content: expect.stringContaining("[sub-agent:Child Worker]"), - metadata: expect.objectContaining({ - source: "subagent_status_rollup", - parentWorkerId: "parent-worker" - }) - }) - ); - }); - }); - - it("auto-registers unknown native callers as claude-native teammates under the parent worker", 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: { autopilot: { parallelismCap: 6 } } - }, - 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: { modelId: "anthropic/claude-sonnet-4-6" } - } - ], - 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: "native-worker-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-parent", - attemptId: "attempt-parent" - }); - const response = await callTool(handler, "report_status", { - workerId: "parent-worker", - progressPct: 10, - blockers: [], - confidence: 0.7, - nextAction: "Running native sub-task" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.ok).toBe(true); - const insertCall = fixture.runtime.db.run.mock.calls.find((call: any[]) => - String(call[0] ?? "").toLowerCase().includes("insert into orchestrator_team_members") - ); - expect(insertCall).toBeTruthy(); - const metadataJson = String(insertCall?.[1]?.[9] ?? "{}"); - const metadata = JSON.parse(metadataJson); - expect(metadata).toMatchObject({ - source: "claude-native", - parentWorkerId: "parent-worker", - parentStepId: "step-parent", - nativeCallerId: "native-worker-1" - }); - }); - }); - - it("blocks unknown native reports when parent allocation cap is exceeded", async () => { - await withEnv({ ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - fixture.runtime.aiOrchestratorService.getTeamMembers = vi.fn(() => [ - { id: "native-1", source: "claude-native", parentWorkerId: "parent-worker", status: "active", metadata: { source: "claude-native", parentWorkerId: "parent-worker" } }, - { id: "native-2", source: "claude-native", parentWorkerId: "parent-worker", status: "active", metadata: { source: "claude-native", parentWorkerId: "parent-worker" } }, - { id: "native-3", source: "claude-native", parentWorkerId: "parent-worker", status: "active", metadata: { source: "claude-native", parentWorkerId: "parent-worker" } }, - { id: "native-4", source: "claude-native", parentWorkerId: "parent-worker", status: "active", metadata: { source: "claude-native", parentWorkerId: "parent-worker" } } - ]); - 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: "native-worker-over-cap", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-parent", - attemptId: "attempt-parent" - }); - const response = await callTool(handler, "report_status", { - workerId: "parent-worker", - progressPct: 30, - blockers: [], - confidence: 0.6, - nextAction: "Still running" - }); - - expect(response.isError).toBe(true); - expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("allocation cap exceeded"); - const insertCalls = fixture.runtime.db.run.mock.calls.filter((call: any[]) => - String(call[0] ?? "").toLowerCase().includes("insert into orchestrator_team_members") - ); - expect(insertCalls).toHaveLength(0); - }); - }); - - it("surfaces forwarded status rollups through get_pending_messages for parent workers", 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: {} - }, - { - id: "step-child", - runId: "run-1", - missionStepId: null, - stepKey: "child-worker", - stepIndex: 1, - title: "Child Worker", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: "attempt-child", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - completedAt: null, - metadata: { - isSubAgent: true, - parentWorkerId: "parent-worker" - } - } - ], - attempts: [ - { id: "attempt-parent", stepId: "step-parent", status: "running", createdAt: new Date().toISOString() }, - { id: "attempt-child", stepId: "step-child", status: "running", createdAt: new Date().toISOString() } - ], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: null - })); - const childHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(childHandler, { - callerId: "attempt-child", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-child", - attemptId: "attempt-child" - }); - const statusResponse = await callTool(childHandler, "report_status", { - workerId: "child-worker", - progressPct: 60, - blockers: [], - confidence: 0.84, - nextAction: "Finalize patch set" - }); - expect(statusResponse?.isError).toBeUndefined(); - const parentHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(parentHandler, { - callerId: "attempt-parent", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-parent", - attemptId: "attempt-parent" - }); - const pending = await callTool(parentHandler, "get_pending_messages", {}); - expect(pending?.isError).toBeUndefined(); - expect(pending.structuredContent.workerAttemptId).toBe("attempt-parent"); - expect(pending.structuredContent.messages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - role: "agent", - content: expect.stringContaining("[sub-agent:Child Worker]"), - metadata: expect.objectContaining({ - source: "subagent_status_rollup" - }) - }) - ]) - ); - }); - }); - - it("queues message_worker handoffs for get_pending_messages when live delivery is unavailable", 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-a", - runId: "run-1", - missionStepId: null, - stepKey: "worker-a", - stepIndex: 0, - title: "Worker A", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: "attempt-a", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - completedAt: null, - metadata: {} - }, - { - id: "step-b", - runId: "run-1", - missionStepId: null, - stepKey: "worker-b", - stepIndex: 1, - title: "Worker B", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: "attempt-b", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - completedAt: null, - metadata: {} - } - ], - attempts: [ - { id: "attempt-a", stepId: "step-a", status: "running", createdAt: new Date().toISOString(), executorSessionId: "session-a" }, - { id: "attempt-b", stepId: "step-b", status: "running", createdAt: new Date().toISOString(), executorSessionId: "session-b" } - ], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: null - })); - - const senderHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(senderHandler, { - callerId: "attempt-a", - role: "agent", - missionId: "mission-1", - runId: "run-1", - stepId: "step-a", - attemptId: "attempt-a" - }); - const sent = await callTool(senderHandler, "message_worker", { - toWorkerId: "worker-b", - content: "contract is ready", - }); - - expect(sent?.isError).toBeUndefined(); - expect(sent.structuredContent.ok).toBe(true); - expect(sent.structuredContent.delivered).toBe(false); - expect(sent.structuredContent.reason).toBe("queued_for_polling"); - expect(fixture.runtime.aiOrchestratorService.sendAgentMessage).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - fromAttemptId: "attempt-a", - toAttemptId: "attempt-b", - content: "contract is ready", - metadata: expect.objectContaining({ - source: "message_worker", - fromWorkerId: "worker-a", - toWorkerId: "worker-b", - queuedForPolling: true - }) - }) - ); - - const recipientHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(recipientHandler, { - callerId: "attempt-b", - role: "agent", - missionId: "mission-1", - runId: "run-1", - stepId: "step-b", - attemptId: "attempt-b" - }); - const pending = await callTool(recipientHandler, "get_pending_messages", {}); - - expect(pending?.isError).toBeUndefined(); - expect(pending.structuredContent.messages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - role: "agent", - content: "contract is ready", - metadata: expect.objectContaining({ - source: "message_worker", - interAgentDelivery: true - }) - }) - ]) - ); - }); - }); - - it("surfaces native terminal rollups through get_pending_messages after 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 nativeHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(nativeHandler, { - callerId: "native-worker-result", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-parent", - attemptId: "attempt-parent" - }); - const resultResponse = await callTool(nativeHandler, "report_result", { - workerId: "parent-worker", - outcome: "succeeded", - summary: "Native child done.", - artifacts: [], - filesChanged: [], - testsRun: null - }); - expect(resultResponse?.isError).toBeUndefined(); - - const parentHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(parentHandler, { - callerId: "attempt-parent", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-parent", - attemptId: "attempt-parent" - }); - const pending = await callTool(parentHandler, "get_pending_messages", {}); - - expect(pending?.isError).toBeUndefined(); - expect(pending.structuredContent.messages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - role: "agent", - content: expect.stringContaining("completed (succeeded): Native child done."), - metadata: expect.objectContaining({ - source: "subagent_result_rollup" - }) - }) - ]) - ); - }); - }); - - it("infers workerId for report_result from initialized worker identity", 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 without explicitly sending workerId.", - artifacts: [], - filesChanged: [], - testsRun: null - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent).toEqual(expect.objectContaining({ - ok: true, - report: expect.objectContaining({ - workerId: "parent-worker", - stepId: "step-parent", - outcome: "succeeded", - }), - })); - }); - }); - - 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)") - }); - 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)"); - }); - }); it("materializes compact context manifests for spawn_agent to keep prompts lightweight", async () => { const fixture = createRuntime(); @@ -3885,7 +2356,7 @@ describe("adeRpcServer", () => { expect(fs.existsSync(contextPath)).toBe(true); const manifest = JSON.parse(fs.readFileSync(contextPath, "utf8")); expect(manifest.schema).toBe("ade.agent.spawnContext.v1"); - expect(manifest.mission.runId).toBe("run-123"); + expect(manifest.runContext.runId).toBe("run-123"); }); it("routes run_tests for suite and ad-hoc command contracts", async () => { @@ -3919,42 +2390,6 @@ describe("adeRpcServer", () => { expect(commandResult.structuredContent.mode).toBe("command"); }); - it("routes ask_user to mission interventions", async () => { - await withEnv({ ADE_MISSION_ID: "mission-1", ADE_RUN_ID: "run-1" }, async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { callerId: "coord-1", role: "orchestrator", missionId: "mission-1", runId: "run-1" }); - const response = await callTool(handler, "ask_user", { - missionId: "mission-1", - title: "Need decision", - body: "Choose the merge order", - phase: "planning" - }); - - expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.missionService.addIntervention).toHaveBeenCalledTimes(1); - expect(fixture.runtime.missionService.addIntervention).toHaveBeenCalledWith(expect.objectContaining({ - metadata: expect.objectContaining({ - source: "ask_user", - runId: "run-1", - phase: "planning", - questionOwnerKind: "coordinator", - questionOwnerLabel: "Coordinator question", - blocking: true, - canProceedWithoutAnswer: false, - }), - })); - expect(fixture.runtime.orchestratorService.pauseRun).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - metadata: expect.objectContaining({ - interventionSource: "ask_user", - }), - })); - expect(response.structuredContent.awaitingUserResponse).toBe(true); - expect(response.structuredContent.blocking).toBe(true); - }); - }); it("returns explicit declined semantics for standalone ask_user with structured questions", async () => { await withEnv({ ADE_CHAT_SESSION_ID: "chat-session-env" }, async () => { @@ -4067,113 +2502,7 @@ describe("adeRpcServer", () => { }); }); - it("stamps worker-owned ask_user provenance and enforces per-step phase policy", 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" }, - steps: [ - { - id: "step-plan-1", - stepKey: "planning-worker", - laneId: "lane-1", - status: "running", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - phaseAskQuestions: { enabled: true, maxQuestions: 2 }, - }, - }, - ], - attempts: [{ id: "attempt-1", stepId: "step-plan-1", status: "running" }], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false }, - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "attempt-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-plan-1", - attemptId: "attempt-1", - }); - const response = await callTool(handler, "ask_user", { - missionId: "mission-1", - title: "Need product direction", - body: "Should the planner optimize for a lightweight patch or a more complete refactor?", - }); - - expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.missionService.addIntervention).toHaveBeenCalledWith(expect.objectContaining({ - metadata: expect.objectContaining({ - source: "ask_user", - runId: "run-1", - phase: "planning", - phaseName: "Planning", - stepId: "step-plan-1", - stepKey: "planning-worker", - questionOwnerKind: "planner", - questionOwnerLabel: "Planner question", - }), - })); - expect(response.structuredContent.awaitingUserResponse).toBe(true); - expect(response.structuredContent.blocking).toBe(true); - }); - }); - - it("denies worker ask_user when the current phase disables questions", 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" }, - steps: [ - { - id: "step-dev-1", - stepKey: "development-worker", - laneId: "lane-1", - status: "running", - metadata: { - phaseKey: "development", - phaseName: "Development", - phaseAskQuestions: { enabled: false }, - }, - }, - ], - attempts: [{ id: "attempt-1", stepId: "step-dev-1", status: "running" }], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: { complete: false }, - })); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { - callerId: "attempt-1", - role: "agent", - missionId: "mission-1", - runId: "run-from-identity", - stepId: "step-dev-1", - attemptId: "attempt-1", - }); - const response = await callTool(handler, "ask_user", { - missionId: "mission-1", - title: "Need guidance", - body: "Should I stop and ask a development question?", - }); - expect(response?.isError).toBe(true); - expect(JSON.stringify(response?.error ?? response?.structuredContent ?? {})).toContain("Ask Questions is disabled for this phase"); - expect(fixture.runtime.missionService.addIntervention).not.toHaveBeenCalled(); - }); - }); it("allows mutations for any session", async () => { const fixture = createRuntime(); @@ -4383,9 +2712,8 @@ describe("adeRpcServer", () => { const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" }); expect(allDomains?.isError).toBeUndefined(); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "ai")).toBe(true); - expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "mission")).toBe(true); - expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator")).toBe(true); - expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator_core")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator")).toBe(false); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator_core")).toBe(false); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "cto_state")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "worker_agent")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "computer_use_artifacts")).toBe(true); @@ -4527,61 +2855,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.result.config.budgets.unlimited).toBe(true); }); - it("binds service method context when invoking dynamic ADE actions", async () => { - const fixture = createRuntime(); - const missionService = fixture.runtime.missionService as any; - missionService.create = vi.fn(function (this: { get: (missionId: string) => unknown }, args: { prompt: string }) { - expect(args.prompt).toBe("smoke mission"); - return this.get("mission-new"); - }); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "agent-1", role: "agent" }); - const response = await callTool(handler, "run_ade_action", { - domain: "mission", - action: "create", - args: { prompt: "smoke mission" }, - }); - - expect(response?.isError).toBeUndefined(); - expect(missionService.create).toHaveBeenCalledWith({ prompt: "smoke mission" }); - expect(response.structuredContent.result).toMatchObject({ id: "mission-new", status: "running" }); - expect(response.structuredContent.statusHints.missionId).toBe("mission-new"); - }); - - it("compacts orchestrator ADE action results for runtime transport", async () => { - const fixture = createRuntime(); - const docs = Array.from({ length: 16 }, (_, index) => ({ - path: index === 0 ? ".ade/internal.md" : `docs/${index}.md`, - bytes: index + 1, - sha256: `sha-${index}`, - })); - fixture.runtime.orchestratorService.listRuns.mockReturnValueOnce([ - { - id: "run-compact", - missionId: "mission-1", - status: "running", - metadata: { runtimeCursor: { docs } }, - }, - ]); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "agent-1", role: "agent" }); - - const response = await callTool(handler, "run_ade_action", { - domain: "orchestrator_core", - action: "listRuns", - args: { limit: 10 }, - }); - - expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.orchestratorService.listRuns).toHaveBeenCalledWith({ limit: 10 }); - const runs = response.structuredContent.result; - expect(runs).toHaveLength(1); - const cursor = runs[0].metadata.runtimeCursor; - expect(cursor.docs).toHaveLength(12); - expect(cursor.docs.map((entry: { path: string }) => entry.path)).not.toContain(".ade/internal.md"); - expect(cursor.docsOmittedCount).toBe(4); - }); it("does not expose unlisted service methods through dynamic ADE actions", async () => { const fixture = createRuntime(); @@ -4627,25 +2901,21 @@ describe("adeRpcServer", () => { ); }); - it("reads ADE action status snapshots across operation/test/chat/mission/run", async () => { + it("reads ADE action status snapshots across operation/test/chat/pr", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "agent-1", role: "agent", runId: "run-1", missionId: "mission-1" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); const response = await callTool(handler, "get_ade_action_status", { operationId: "op-1", testRunId: "test-run-1", chatSessionId: "chat-1", - runId: "run-1", - missionId: "mission-1", prId: "pr-1", }); expect(response?.isError).toBeUndefined(); expect(response.structuredContent.operation.id).toBe("op-1"); expect(response.structuredContent.testRun.id).toBe("test-run-1"); expect(response.structuredContent.chatSession.sessionId).toBe("chat-1"); - expect(response.structuredContent.runGraph.run.id).toBe("run-1"); - expect(response.structuredContent.mission.id).toBe("mission-1"); expect(response.structuredContent.pr.health.prId).toBe("pr-1"); expect(typeof response.structuredContent.hash).toBe("string"); expect(response.structuredContent.changed).toBe(true); @@ -4654,8 +2924,6 @@ describe("adeRpcServer", () => { operationId: "op-1", testRunId: "test-run-1", chatSessionId: "chat-1", - runId: "run-1", - missionId: "mission-1", prId: "pr-1", previousHash: response.structuredContent.hash, waitForMs: 0, @@ -4789,16 +3057,15 @@ describe("adeRpcServer", () => { // Create two independent sessions (simulating session recycling) const fixture1 = createRuntime(); const handler1 = createAdeRpcRequestHandler({ runtime: fixture1.runtime, serverVersion: "test" }); - await initialize(handler1); + await initialize(handler1, { callerId: "chat-1", role: "agent", chatSessionId: "chat-1" }); const fixture2 = createRuntime(); const handler2 = createAdeRpcRequestHandler({ runtime: fixture2.runtime, serverVersion: "test" }); - await initialize(handler2); + await initialize(handler2, { callerId: "chat-2", role: "agent", chatSessionId: "chat-2" }); // Fire 6 calls from session 1 (per-session limit) for (let i = 0; i < 6; i++) { const r = await callTool(handler1, "ask_user", { - missionId: "mission-1", title: `Question ${i}`, body: `Body ${i}` }); @@ -4807,7 +3074,6 @@ describe("adeRpcServer", () => { // Session 1 should be rate-limited (per-session: 6/min) const overLimit = await callTool(handler1, "ask_user", { - missionId: "mission-1", title: "Over limit", body: "Should fail" }); @@ -4818,7 +3084,6 @@ describe("adeRpcServer", () => { // but global limit is 20, so with 6 from session 1, session 2 can do 6 more for (let i = 0; i < 6; i++) { const r = await callTool(handler2, "ask_user", { - missionId: "mission-1", title: `S2 Question ${i}`, body: `S2 Body ${i}` }); @@ -5225,191 +3490,9 @@ describe("adeRpcServer", () => { expect(JSON.stringify(response.structuredContent ?? {})).toContain("suiteId or command"); }); - // ---------- Mission Lifecycle Tools ---------- - - it("routes create_mission with orchestration authorization", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "create_mission", { - prompt: "Build the authentication module", - title: "Auth Module", - priority: "high" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.mission.id).toBe("mission-new"); - expect(response.structuredContent.mission.prompt).toBe("Build the authentication module"); - expect(response.structuredContent.mission.status).toBe("planned"); - expect(fixture.runtime.missionService.create).toHaveBeenCalledWith( - expect.objectContaining({ prompt: "Build the authentication module", title: "Auth Module", priority: "high" }) - ); - expect(fixture.runtime.eventBuffer.push).toHaveBeenCalledWith( - expect.objectContaining({ - category: "mission", - payload: expect.objectContaining({ type: "mission_created", missionId: "mission-new" }) - }) - ); - }); - - it("routes start_mission and returns run info", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "start_mission", { - missionId: "mission-1", - runMode: "autopilot" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.runId).toBe("run-1"); - expect(response.structuredContent.started.run.status).toBe("running"); - expect(fixture.runtime.aiOrchestratorService.startMissionRun).toHaveBeenCalledWith( - expect.objectContaining({ missionId: "mission-1", runMode: "autopilot" }) - ); - expect(fixture.runtime.eventBuffer.push).toHaveBeenCalledWith( - expect.objectContaining({ - category: "mission", - payload: expect.objectContaining({ type: "mission_started", missionId: "mission-1", runId: "run-1" }) - }) - ); - }); - - it("routes pause_mission to orchestratorService.pauseRun", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "pause_mission", { - runId: "run-1", - reason: "User requested pause" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.run.id).toBe("run-1"); - expect(response.structuredContent.run.status).toBe("paused"); - expect(fixture.runtime.orchestratorService.pauseRun).toHaveBeenCalledWith( - expect.objectContaining({ runId: "run-1", reason: "User requested pause" }) - ); - }); - - it("routes resume_mission through aiOrchestratorService.resumeRun so coordinator runtime restarts", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "resume_mission", { runId: "run-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.run.id).toBe("run-1"); - expect(response.structuredContent.run.status).toBe("running"); - expect(fixture.runtime.aiOrchestratorService.resumeRun).toHaveBeenCalledWith({ runId: "run-1" }); - }); - - it("routes cancel_mission to aiOrchestratorService.cancelRunGracefully", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "cancel_mission", { - runId: "run-1", - reason: "No longer needed" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.cancelled).toBe(true); - expect(response.structuredContent.runId).toBe("run-1"); - expect(fixture.runtime.aiOrchestratorService.cancelRunGracefully).toHaveBeenCalledWith( - expect.objectContaining({ runId: "run-1", reason: "No longer needed" }) - ); - }); - - it("routes steer_mission with directive", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "steer_mission", { - missionId: "mission-1", - directive: "Focus on API layer first", - interventionId: "intervention-1", - resolutionKind: "answer_provided", - targetStepKey: "step-a", - priority: "instruction" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.acknowledged).toBe(true); - expect(fixture.runtime.aiOrchestratorService.steerMission).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - directive: "Focus on API layer first", - interventionId: "intervention-1", - resolutionKind: "answer_provided", - priority: "instruction", - targetStepKey: "step-a" - }) - ); - }); - - it("routes resolve_intervention with status", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "resolve_intervention", { - missionId: "mission-1", - interventionId: "intervention-1", - status: "resolved", - resolutionKind: "skip_question", - note: "Issue addressed" - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.intervention.id).toBe("intervention-1"); - expect(response.structuredContent.intervention.status).toBe("resolved"); - expect(fixture.runtime.missionService.resolveIntervention).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - interventionId: "intervention-1", - status: "resolved", - resolutionKind: "skip_question", - note: "Issue addressed" - }) - ); - }); - // ---------- Observation Tools ---------- - it("routes get_mission to missionService.get", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_mission", { missionId: "mission-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.mission.id).toBe("mission-1"); - expect(fixture.runtime.missionService.get).toHaveBeenCalledWith("mission-1"); - }); - - it("routes get_run_graph to orchestratorService.getRunGraph", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_run_graph", { runId: "run-1", timelineLimit: 50 }); - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.graph.run.id).toBe("run-1"); - expect(response.structuredContent.graph.steps).toHaveLength(1); - expect(response.structuredContent.graph.completionEvaluation.complete).toBe(true); - expect(fixture.runtime.orchestratorService.getRunGraph).toHaveBeenCalledWith( - expect.objectContaining({ runId: "run-1", timelineLimit: 50 }) - ); - }); it("routes stream_events to eventBuffer.drain", async () => { const fixture = createRuntime(); @@ -5425,264 +3508,19 @@ describe("adeRpcServer", () => { expect(fixture.runtime.eventBuffer.drain).toHaveBeenCalledWith(0, 50); }); - it("routes get_step_output and filters attempts by step", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_step_output", { - runId: "run-1", - stepKey: "step-a" - }); - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.step.stepKey).toBe("step-a"); - expect(response.structuredContent.attempts).toHaveLength(1); - expect(response.structuredContent.attempts[0].stepId).toBe("step-1"); - expect(fixture.runtime.orchestratorService.getRunGraph).toHaveBeenCalledWith( - expect.objectContaining({ runId: "run-1", timelineLimit: 0 }) - ); - }); - it("routes get_step_output returns error for unknown step", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_step_output", { - runId: "run-1", - stepKey: "nonexistent-step" - }); - expect(response.isError).toBe(true); - expect(JSON.stringify(response.structuredContent ?? {})).toContain("Step not found"); - }); - - it("routes get_worker_states to aiOrchestratorService.getWorkerStates", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - fixture.runtime.orchestratorService.getRunGraph.mockReturnValueOnce({ - run: { id: "run-1", missionId: "mission-1", status: "running" }, - steps: [ - { id: "step-1", stepKey: "step-a", laneId: "lane-1", title: "Worker", status: "completed", retryCount: 0, metadata: {} }, - { id: "task-1", stepKey: "task-a", laneId: null, title: "Display task", status: "pending", retryCount: 0, metadata: { isTask: true } }, - { id: "system-1", stepKey: "system-a", laneId: null, title: "System", status: "pending", retryCount: 0, metadata: { systemManaged: true } }, - { id: "tracker-1", stepKey: "tracker-a", laneId: null, title: "Tracker", status: "pending", retryCount: 0, metadata: { plannerLaunchTracker: true } }, - { id: "planner-1", stepKey: "__planner__", laneId: null, title: "Planner", status: "succeeded", retryCount: 0, metadata: {} }, - ], - attempts: [{ id: "attempt-1", stepId: "step-1", status: "completed" }], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - completionEvaluation: null, - }); - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_worker_states", { runId: "run-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.runId).toBe("run-1"); - expect(response.structuredContent.workers).toHaveLength(1); - expect(response.structuredContent.workers[0].state).toBe("running"); - expect(response.structuredContent.runWorkers).toHaveLength(1); - expect(response.structuredContent.runWorkers[0]).toMatchObject({ - workerId: "step-a", - status: "completed", - }); - expect(fixture.runtime.aiOrchestratorService.getWorkerStates).toHaveBeenCalledWith({ runId: "run-1" }); - expect(fixture.runtime.orchestratorService.getRunGraph).toHaveBeenCalledWith( - expect.objectContaining({ runId: "run-1", timelineLimit: 0 }) - ); - }); - - it("routes get_timeline to orchestratorService.listTimeline", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_timeline", { runId: "run-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.timeline).toHaveLength(2); - expect(response.structuredContent.timeline[0].eventType).toBe("run_started"); - expect(fixture.runtime.orchestratorService.listTimeline).toHaveBeenCalledWith( - expect.objectContaining({ runId: "run-1", limit: 300 }) - ); - }); - - it("routes get_timeline with stepId filter", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_timeline", { runId: "run-1", stepId: "step-1" }); - - expect(response?.isError).toBeUndefined(); - // Only the entry with stepId "step-1" should be returned - expect(response.structuredContent.timeline).toHaveLength(1); - expect(response.structuredContent.timeline[0].stepId).toBe("step-1"); - }); - - it("routes get_mission_metrics to aiOrchestratorService.getMissionMetrics", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_mission_metrics", { missionId: "mission-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.metrics.missionId).toBe("mission-1"); - expect(response.structuredContent.metrics.samples).toEqual([]); - expect(fixture.runtime.aiOrchestratorService.getMissionMetrics).toHaveBeenCalledWith({ missionId: "mission-1" }); - }); - - it("routes get_final_diff with per-lane diffs", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_final_diff", { runId: "run-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.runId).toBe("run-1"); - // The mock graph has one step with laneId "lane-1", so we should get diffs for that lane - expect(response.structuredContent.diffs["lane-1"]).toBeDefined(); - expect(fixture.runtime.diffService.getChanges).toHaveBeenCalledWith("lane-1"); - }); // ---------- Evaluation Tools ---------- - it("routes evaluate_run with evaluator authorization and writes to DB", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - const response = await callTool(handler, "evaluate_run", { - runId: "run-1", - missionId: "mission-1", - scores: { - planQuality: 8, - parallelism: 7, - coordinatorDecisions: 9, - resourceEfficiency: 6, - outcomeQuality: 8 - }, - issues: [ - { - category: "planning", - severity: "minor", - description: "Could have parallelized more", - recommendation: "Use wider lanes" - } - ], - summary: "Good overall execution with minor planning gaps", - improvements: ["Increase lane parallelism"], - metadata: { evaluatorVersion: "1.0" } - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.runId).toBe("run-1"); - expect(response.structuredContent.missionId).toBe("mission-1"); - expect(response.structuredContent.scores.planQuality).toBe(8); - expect(response.structuredContent.summary).toBe("Good overall execution with minor planning gaps"); - expect(response.structuredContent.id).toBeTruthy(); - expect(response.structuredContent.evaluatedAt).toBeTruthy(); - expect(fixture.runtime.db.run).toHaveBeenCalledTimes(1); - // Verify the INSERT call has the correct SQL and the run_id parameter - const runCallArgs = fixture.runtime.db.run.mock.calls[0]; - expect(runCallArgs[0]).toContain("INSERT INTO orchestrator_evaluations"); - expect(runCallArgs[1]).toContain("run-1"); - expect(runCallArgs[1]).toContain("mission-1"); - }); - - it("routes list_evaluations and returns summaries", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "list_evaluations", { - missionId: "mission-1", - limit: 10 - }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.evaluations).toHaveLength(1); - expect(response.structuredContent.evaluations[0].id).toBe("eval-1"); - expect(response.structuredContent.evaluations[0].scores.planQuality).toBe(8); - expect(response.structuredContent.evaluations[0].issueCount).toBe(0); - expect(response.structuredContent.evaluations[0].summary).toBe("Good run"); - expect(fixture.runtime.db.all).toHaveBeenCalled(); - }); - - it("routes get_evaluation_report with run context", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "external" }); - const response = await callTool(handler, "get_evaluation_report", { evaluationId: "eval-1" }); - - expect(response?.isError).toBeUndefined(); - expect(response.structuredContent.evaluation.id).toBe("eval-1"); - expect(response.structuredContent.evaluation.runId).toBe("run-1"); - expect(response.structuredContent.evaluation.scores.planQuality).toBe(8); - expect(response.structuredContent.evaluation.summary).toBe("Good run"); - // run context should be populated from orchestratorService.getRunGraph - expect(response.structuredContent.runContext).toBeDefined(); - expect(response.structuredContent.runContext.run.id).toBe("run-1"); - expect(response.structuredContent.runContext.stepCount).toBe(1); - expect(response.structuredContent.runContext.attemptCount).toBe(1); - }); - - it("evaluator gets reads + orchestration + evaluation", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - - await initialize(handler, { role: "evaluator" }); - // Read-only observation tool should work - const readResponse = await callTool(handler, "get_mission", { missionId: "mission-1" }); - expect(readResponse?.isError).toBeUndefined(); - // Orchestration tool should work - const orchResponse = await callTool(handler, "pause_mission", { runId: "run-1" }); - expect(orchResponse?.isError).toBeUndefined(); - // Evaluation tool should work - const evalResponse = await callTool(handler, "evaluate_run", { - runId: "run-1", - missionId: "mission-1", - scores: { planQuality: 8, parallelism: 7, coordinatorDecisions: 9, resourceEfficiency: 6, outcomeQuality: 8 }, - issues: [], - summary: "Test" - }); - expect(evalResponse?.isError).toBeUndefined(); - }); - - it("any session can access observation tools", async () => { - const fixture = createRuntime(); - const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { role: "external" }); - - // Observation tools should be accessible - const missionResp = await callTool(handler, "get_mission", { missionId: "mission-1" }); - expect(missionResp?.isError).toBeUndefined(); - - const graphResp = await callTool(handler, "get_run_graph", { runId: "run-1" }); - expect(graphResp?.isError).toBeUndefined(); - - const timelineResp = await callTool(handler, "get_timeline", { runId: "run-1" }); - expect(timelineResp?.isError).toBeUndefined(); - - // Evaluation read tools should also be accessible - const listResp = await callTool(handler, "list_evaluations", {}); - expect(listResp?.isError).toBeUndefined(); - - const reportResp = await callTool(handler, "get_evaluation_report", { evaluationId: "eval-1" }); - expect(reportResp?.isError).toBeUndefined(); - }); // ---------- Event Streaming Tests ---------- @@ -5725,7 +3563,7 @@ describe("adeRpcServer", () => { fixture.runtime.eventBuffer.drain = vi.fn((cursor: number) => ({ events: [ { id: cursor + 1, timestamp: new Date().toISOString(), category: "orchestrator", payload: { type: "step_started" } }, - { id: cursor + 2, timestamp: new Date().toISOString(), category: "mission", payload: { type: "mission_created" } }, + { id: cursor + 2, timestamp: new Date().toISOString(), category: "runtime", payload: { type: "terminal_session_changed" } }, { id: cursor + 3, timestamp: new Date().toISOString(), category: "orchestrator", payload: { type: "step_completed" } } ], nextCursor: cursor + 3, @@ -5809,8 +3647,8 @@ describe("adeRpcServer", () => { { id: cursor + 4, timestamp: new Date().toISOString(), - category: "mission", - payload: { type: "mission_created" } + category: "pty", + payload: { type: "pty_exit" } } ], nextCursor: cursor + 4, @@ -5855,7 +3693,6 @@ describe("adeRpcServer", () => { role: "external", chatSessionId: null, standaloneChatSession: false, - missionId: null, runId: null, stepId: null, attemptId: null, diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 653365b96..1169aaa32 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -3,11 +3,6 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { createCtoOperatorTools } from "../../desktop/src/main/services/ai/tools/ctoOperatorTools"; -import { - createCoordinatorToolSet, - type CoordinatorExecutableTool, -} from "../../desktop/src/main/services/orchestrator/coordinatorTools"; -import { isDisplayOnlyTaskStep } from "../../desktop/src/main/services/orchestrator/orchestratorContext"; import { createComputerUseArtifactPath, getLocalComputerUseCapabilities, @@ -23,8 +18,6 @@ import { isCtoOnlyAdeAction, listAllowedAdeActionNames, } from "../../desktop/src/main/services/adeActions/registry"; -import { ReflectionValidationError } from "../../desktop/src/main/services/orchestrator/orchestratorService"; -import { getTeamMembersForRun, registerTeamMember, updateTeamMemberStatus } from "../../desktop/src/main/services/orchestrator/teamRuntimeState"; import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../../desktop/src/main/services/prs/prIssueResolver"; import { runGit } from "../../desktop/src/main/services/git/git"; import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils"; @@ -154,7 +147,6 @@ type SessionIdentity = { role: "cto" | "orchestrator" | "agent" | "external" | "evaluator"; chatSessionId: string | null; standaloneChatSession: boolean; - missionId: string | null; runId: string | null; stepId: string | null; attemptId: string | null; @@ -344,7 +336,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "get_ade_action_status", - description: "Check status/progress for long-running ADE actions by operation/test/chat/run/mission identifiers.", + description: "Check status/progress for long-running ADE actions by operation/test/chat/run identifiers.", inputSchema: { type: "object", additionalProperties: false, @@ -353,7 +345,6 @@ const TOOL_SPECS: ToolSpec[] = [ testRunId: { type: "string", minLength: 1 }, chatSessionId: { type: "string", minLength: 1 }, runId: { type: "string", minLength: 1 }, - missionId: { type: "string", minLength: 1 }, prId: { type: "string", minLength: 1 }, previousHash: { type: "string" }, waitForMs: { type: "number", minimum: 0, maximum: 120000, default: 0 }, @@ -390,13 +381,12 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "ask_user", - description: "Ask the user a question and wait for their answer. Works in both mission contexts (with missionId) and standalone chat sessions (without missionId). Returns explicit outcome fields (`outcome`, `resolved`, `answered`, `declined`, `cancelled`, `timedOut`, `awaitingUserResponse`) so declines/cancels/timeouts cannot be mistaken for a still-pending question.", + description: "Ask the user a question and wait for their answer from an active chat session. Returns explicit outcome fields (`outcome`, `resolved`, `answered`, `declined`, `cancelled`, `timedOut`, `awaitingUserResponse`) so declines/cancels/timeouts cannot be mistaken for a still-pending question.", inputSchema: { type: "object", required: ["title", "body"], additionalProperties: false, properties: { - missionId: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1 }, body: { type: "string", minLength: 1 }, questions: { @@ -443,32 +433,6 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, - { - name: "reflection_add", - description: "Record a structured reflection entry for mission introspection and retrospective synthesis.", - inputSchema: { - type: "object", - required: ["signalType", "observation", "agentRole", "phase", "recommendation", "context", "occurredAt"], - additionalProperties: false, - properties: { - missionId: { type: "string" }, - runId: { type: "string" }, - stepId: { type: "string" }, - attemptId: { type: "string" }, - agentRole: { type: "string", minLength: 1 }, - phase: { type: "string", minLength: 1 }, - signalType: { type: "string", enum: ["wish", "frustration", "idea", "pattern", "limitation"] }, - observation: { type: "string", minLength: 1 }, - recommendation: { type: "string", minLength: 1 }, - context: { type: "string", minLength: 1 }, - occurredAt: { - type: "string", - minLength: 1, - pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?(?:Z|[+-]\\d{2}:\\d{2})$" - } - } - } - }, { name: "get_environment_info", description: "Inspect ADE local fallback computer-use capability state, frontmost app context, and ADE artifact paths.", @@ -709,10 +673,6 @@ const TOOL_SPECS: ToolSpec[] = [ type: "string", enum: [ "lane", - "mission", - "orchestrator_run", - "orchestrator_step", - "orchestrator_attempt", "chat_session", "automation_run", "github_pr", @@ -746,10 +706,6 @@ const TOOL_SPECS: ToolSpec[] = [ type: "string", enum: [ "lane", - "mission", - "orchestrator_run", - "orchestrator_step", - "orchestrator_attempt", "chat_session", "automation_run", "github_pr", @@ -1341,132 +1297,7 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, - // ── Mission Lifecycle Tools ────────────────────────────────────── - { - name: "create_mission", - description: "Create a new mission from a prompt.", - inputSchema: { - type: "object", - required: ["prompt"], - additionalProperties: false, - properties: { - prompt: { type: "string", minLength: 1 }, - title: { type: "string" }, - laneId: { type: "string" }, - priority: { type: "string", enum: ["low", "medium", "high", "critical"] }, - executionMode: { type: "string", enum: ["autopilot", "manual"] }, - prStrategy: { type: "string", enum: ["integration", "per-lane", "manual"] }, - executorPolicy: { type: "object" } - } - } - }, - { - name: "start_mission", - description: "Start a mission run, triggering planning and execution.", - inputSchema: { - type: "object", - required: ["missionId"], - additionalProperties: false, - properties: { - missionId: { type: "string", minLength: 1 }, - runMode: { type: "string", enum: ["autopilot", "manual"] }, - defaultExecutorKind: { type: "string" }, - coordinatorModel: { type: "string" } - } - } - }, - { - name: "pause_mission", - description: "Pause an active mission run.", - inputSchema: { - type: "object", - required: ["runId"], - additionalProperties: false, - properties: { - runId: { type: "string", minLength: 1 }, - reason: { type: "string" } - } - } - }, - { - name: "resume_mission", - description: "Resume a paused mission run.", - inputSchema: { - type: "object", - required: ["runId"], - additionalProperties: false, - properties: { - runId: { type: "string", minLength: 1 } - } - } - }, - { - name: "cancel_mission", - description: "Cancel an active mission run gracefully.", - inputSchema: { - type: "object", - required: ["runId"], - additionalProperties: false, - properties: { - runId: { type: "string", minLength: 1 }, - reason: { type: "string" } - } - } - }, - { - name: "steer_mission", - description: "Inject a steering directive into an active mission.", - inputSchema: { - type: "object", - required: ["missionId", "directive"], - additionalProperties: false, - properties: { - missionId: { type: "string", minLength: 1 }, - directive: { type: "string", minLength: 1 }, - targetStepKey: { type: "string" }, - priority: { type: "string", enum: ["suggestion", "instruction", "override"] } - } - } - }, - { - name: "resolve_intervention", - description: "Resolve an open mission intervention.", - inputSchema: { - type: "object", - required: ["missionId", "interventionId", "status"], - additionalProperties: false, - properties: { - missionId: { type: "string", minLength: 1 }, - interventionId: { type: "string", minLength: 1 }, - status: { type: "string", enum: ["resolved", "dismissed"] }, - note: { type: "string" } - } - } - }, // ── Observation Tools ──────────────────────────────────────────── - { - name: "get_mission", - description: "Get full mission details including steps, interventions, and metadata. When called by a worker, missionId defaults to the worker's own mission.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - missionId: { type: "string", description: "Mission ID. Auto-populated from caller context if omitted." } - } - } - }, - { - name: "get_run_graph", - description: "Get the full run graph: run, steps, attempts, claims, timeline. When called by a worker, runId defaults to the worker's own run.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - runId: { type: "string", description: "Run ID. Auto-populated from caller context if omitted." }, - timelineLimit: { type: "number", minimum: 0, maximum: 1000 } - } - } - }, { name: "stream_events", description: "Poll buffered orchestrator events using a cursor for incremental streaming.", @@ -1476,195 +1307,16 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { cursor: { type: "number", minimum: 0 }, limit: { type: "number", minimum: 1, maximum: 1000 }, - category: { type: "string", enum: ["orchestrator", "dag_mutation", "runtime", "mission", "pty"] } - } - } - }, - { - name: "get_step_output", - description: "Get the output/result of a specific step in a run. When called by a worker, runId defaults to the worker's own run.", - inputSchema: { - type: "object", - required: ["stepKey"], - additionalProperties: false, - properties: { - runId: { type: "string", description: "Run ID. Auto-populated from caller context if omitted." }, - stepKey: { type: "string", minLength: 1 } - } - } - }, - { - name: "get_worker_states", - description: "Get active worker runtime states plus persisted run worker rows. When called by a worker, runId defaults to the worker's own run, so you can see your peers without passing parameters.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - runId: { type: "string", description: "Run ID. Auto-populated from caller context if omitted." } - } - } - }, - { - name: "get_timeline", - description: "Get timeline events for a run, optionally filtered by step. When called by a worker, runId defaults to the worker's own run.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - runId: { type: "string", description: "Run ID. Auto-populated from caller context if omitted." }, - limit: { type: "number", minimum: 1, maximum: 1000 }, - stepId: { type: "string" } - } - } - }, - { - name: "list_retrospectives", - description: "List generated retrospectives. When called by a worker, missionId defaults to the worker mission.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - missionId: { type: "string", description: "Mission ID filter. Auto-populated from caller context if omitted." }, - limit: { type: "number", minimum: 1, maximum: 100 } - } - } - }, - { - name: "list_reflection_trends", - description: "List cross-mission reflection trend entries linked to source retrospectives.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - missionId: { type: "string", description: "Mission ID filter. Auto-populated from caller context if omitted." }, - runId: { type: "string", description: "Run ID filter. Auto-populated from caller context if omitted." }, - limit: { type: "number", minimum: 1, maximum: 500 } - } - } - }, - { - name: "list_reflection_pattern_stats", - description: "List reflection pattern repetition stats and candidate-promotion linkage.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - limit: { type: "number", minimum: 1, maximum: 500 } - } - } - }, - { - name: "get_mission_metrics", - description: "Get aggregated metrics for a mission. When called by a worker, missionId defaults to the worker's own mission.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - missionId: { type: "string", description: "Mission ID. Auto-populated from caller context if omitted." } - } - } - }, - { - name: "get_final_diff", - description: "Get the final diff output for a completed run. When called by a worker, runId defaults to the worker's own run.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - runId: { type: "string", description: "Run ID. Auto-populated from caller context if omitted." } - } - } - }, - // ── Evaluation Tools ───────────────────────────────────────────── - { - name: "evaluate_run", - description: "Submit a structured evaluation of a mission run.", - inputSchema: { - type: "object", - required: ["runId", "missionId", "scores", "issues", "summary"], - additionalProperties: false, - properties: { - runId: { type: "string", minLength: 1 }, - missionId: { type: "string", minLength: 1 }, - scores: { - type: "object", - required: ["planQuality", "parallelism", "coordinatorDecisions", "resourceEfficiency", "outcomeQuality"], - additionalProperties: false, - properties: { - planQuality: { type: "number", minimum: 0, maximum: 10 }, - parallelism: { type: "number", minimum: 0, maximum: 10 }, - coordinatorDecisions: { type: "number", minimum: 0, maximum: 10 }, - resourceEfficiency: { type: "number", minimum: 0, maximum: 10 }, - outcomeQuality: { type: "number", minimum: 0, maximum: 10 } - } - }, - issues: { - type: "array", - items: { - type: "object", - required: ["category", "severity", "description", "recommendation"], - additionalProperties: false, - properties: { - category: { type: "string", enum: ["planning", "execution", "coordination", "recovery", "output"] }, - severity: { type: "string", enum: ["minor", "major", "critical"] }, - description: { type: "string", minLength: 1 }, - stepKey: { type: "string" }, - recommendation: { type: "string", minLength: 1 } - } - } - }, - summary: { type: "string", minLength: 1 }, - improvements: { type: "array", items: { type: "string" } }, - metadata: { type: "object" } - } - } - }, - { - name: "list_evaluations", - description: "List evaluations for a mission or run.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - missionId: { type: "string" }, - runId: { type: "string" }, - limit: { type: "number", minimum: 1, maximum: 100 } - } - } - }, - { - name: "get_evaluation_report", - description: "Get a full evaluation report with run context.", - inputSchema: { - type: "object", - required: ["evaluationId"], - additionalProperties: false, - properties: { - evaluationId: { type: "string", minLength: 1 } + category: { type: "string", enum: ["orchestrator", "dag_mutation", "runtime", "pty"] } } } }, - // ── Worker Collaboration Tools ───────────────────────────────── - { - name: "get_pending_messages", - description: "Get pending messages for this worker. Returns messages from coordinator, peers, or system. Uses caller identity from environment to auto-resolve the worker.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - since_cursor: { - type: "string", - description: "Optional ISO timestamp cursor to get messages after a certain point" - }, - limit: { - type: "number", - description: "Maximum number of messages to return (default 50, max 200)" - } - } - } - } ]; +const STANDALONE_CHAT_HIDDEN_TOOL_NAMES = new Set([ + "spawn_agent", +]); + const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ { name: "get_cto_state", @@ -1816,21 +1468,6 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ } } }, - { - name: "routeLinearIssueToMission", - description: "Create a mission from a Linear issue and optionally launch it.", - inputSchema: { - type: "object", - required: ["issueId"], - additionalProperties: false, - properties: { - issueId: { type: "string", minLength: 1 }, - laneId: { type: "string" }, - launch: { type: "boolean" }, - runMode: { type: "string", enum: ["autopilot", "manual"] } - } - } - }, { name: "routeLinearIssueToWorker", description: "Wake a worker agent with a Linear issue as the task context.", @@ -1854,12 +1491,10 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ additionalProperties: false, properties: { runId: { type: "string", minLength: 1 }, - target: { type: "string", enum: ["cto", "mission", "worker"] }, + target: { type: "string", enum: ["cto", "worker"] }, reason: { type: "string", minLength: 1 }, laneId: { type: "string" }, reuseExisting: { type: "boolean" }, - launch: { type: "boolean" }, - runMode: { type: "string", enum: ["autopilot", "manual"] }, agentId: { type: "string" }, taskKey: { type: "string" } } @@ -2000,86 +1635,10 @@ const CTO_LINEAR_SYNC_TOOL_SPECS: ToolSpec[] = [ }, ]; -const COORDINATOR_TOOL_SPECS: ToolSpec[] = [ - { - name: "spawn_worker", - description: "Coordinator: spawn a new worker step in the current mission run.", - inputSchema: { - type: "object", - required: ["name", "prompt"], - additionalProperties: true, - properties: { - name: { type: "string", minLength: 1 }, - prompt: { type: "string", minLength: 1 } - } - } - }, - { name: "insert_milestone", description: "Coordinator: insert a milestone gate into the DAG.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "request_specialist", description: "Coordinator: request a specialist worker role.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "delegate_to_subagent", description: "Coordinator: delegate a subtask from a parent worker.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "delegate_parallel", description: "Coordinator: delegate multiple child subtasks from one parent in a single call.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "stop_worker", description: "Coordinator: stop a running worker attempt.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "send_message", description: "Coordinator: send a direct message to a worker.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "message_worker", description: "Coordinator: relay a message between two workers.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "broadcast", description: "Coordinator: broadcast a message to active workers.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "get_worker_output", description: "Coordinator: fetch latest output/report for a worker step.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "list_workers", description: "Coordinator: list current worker states.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "report_status", description: "Worker: report progress/status to the coordinator.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "report_result", description: "Worker: report completion result to the coordinator.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "report_validation", description: "Validator: report validation verdict/findings for a step.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "read_mission_status", description: "Coordinator: read full mission/run status snapshot.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "read_mission_state", description: "Coordinator: read persisted mission state document.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "update_mission_state", description: "Coordinator: patch persisted mission state document.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "revise_plan", description: "Coordinator: revise mission DAG/plan.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "update_tool_profiles", description: "Coordinator: update runtime role/tool profiles.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "transfer_lane", description: "Coordinator: transfer a worker step to another lane.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "provision_lane", description: "Coordinator: provision a new mission child lane.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "set_current_phase", description: "Coordinator: set the active mission phase.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "create_task", description: "Coordinator: create a logical mission task without spawning a worker.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "update_task", description: "Coordinator: update task metadata/status.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "assign_task", description: "Coordinator: assign a task to an existing worker.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "list_tasks", description: "Coordinator: list mission tasks and status.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "skip_step", description: "Coordinator: skip a worker step.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "mark_step_complete", description: "Coordinator: mark a worker step as succeeded.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "mark_step_failed", description: "Coordinator: mark a worker step as failed.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "retry_step", description: "Coordinator: retry a failed worker step.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "complete_mission", description: "Coordinator: request mission success finalization. The runtime still enforces completion gates before success is granted.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "fail_mission", description: "Coordinator: finalize the mission run as failed.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "get_budget_status", description: "Coordinator: inspect mission budget pressure/hard caps.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "request_user_input", description: "Coordinator: open a user intervention with a question.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "read_file", description: "Coordinator: read a file within project root.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "read_step_output", description: "Coordinator: read output artifact for a specific step.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "search_files", description: "Coordinator: search files/content in project root.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, - { name: "get_project_context", description: "Coordinator: return compact mission/project context for planning.", inputSchema: { type: "object", additionalProperties: true, properties: {} } }, -]; - -const AGENT_VISIBLE_COORDINATOR_TOOL_NAMES = new Set([ - "report_status", - "report_result", - "report_validation", - "message_worker", - "delegate_to_subagent", - "delegate_parallel", - "get_worker_output", - "list_workers", - "read_mission_status", - "read_mission_state", - "list_tasks", - "get_budget_status", - "get_project_context", -]); - -const AGENT_VISIBLE_COORDINATOR_TOOL_SPECS = COORDINATOR_TOOL_SPECS.filter((tool) => - AGENT_VISIBLE_COORDINATOR_TOOL_NAMES.has(tool.name) -); - -const STANDALONE_CHAT_HIDDEN_TOOL_NAMES = new Set([ - "spawn_agent", - ...COORDINATOR_TOOL_SPECS.map((tool) => tool.name), -]); const CTO_OPERATOR_TOOL_NAMES = new Set(CTO_OPERATOR_TOOL_SPECS.map((tool) => tool.name)); const CTO_LINEAR_SYNC_TOOL_NAMES = new Set(CTO_LINEAR_SYNC_TOOL_SPECS.map((tool) => tool.name)); +const DISABLED_ADE_ACTION_DOMAINS = new Set(); const LOCAL_COMPUTER_USE_TOOL_NAMES = new Set([ "get_environment_info", @@ -2100,11 +1659,16 @@ const MACOS_VM_TOOL_NAMES = new Set([ "macos_vm_type", ]); -const COORDINATOR_TOOL_NAMES = new Set(COORDINATOR_TOOL_SPECS.map((tool) => tool.name)); +const ALL_TOOL_SPECS: ToolSpec[] = [ + ...TOOL_SPECS, + ...CTO_OPERATOR_TOOL_SPECS, + ...CTO_LINEAR_SYNC_TOOL_SPECS, +]; const READ_ONLY_TOOLS = new Set([ "check_conflicts", "list_ade_actions", "get_ade_action_status", + "stream_events", "get_lane_status", "get_lane_conflict_state", "list_lanes", @@ -2178,14 +1742,12 @@ const MUTATION_TOOLS = new Set([ "pr_start_issue_resolution", "pr_reply_to_review_thread", "pr_resolve_review_thread", - "reflection_add", "spawnChat", "sendChatMessage", "interruptChat", "resolveLinearRunAction", "cancelLinearRun", "routeLinearIssueToCto", - "routeLinearIssueToMission", "routeLinearIssueToWorker", "rerouteLinearRun", "runLinearSyncNow", @@ -2206,40 +1768,6 @@ const MUTATION_TOOLS = new Set([ "spawn_agent" ]); -const ORCHESTRATION_TOOLS = new Set([ - "create_mission", - "start_mission", - "pause_mission", - "resume_mission", - "cancel_mission", - "steer_mission", - "resolve_intervention" -]); - -const OBSERVATION_TOOLS = new Set([ - "get_mission", - "get_run_graph", - "stream_events", - "get_step_output", - "get_worker_states", - "get_timeline", - "list_retrospectives", - "list_reflection_trends", - "list_reflection_pattern_stats", - "get_mission_metrics", - "get_final_diff", - "get_pending_messages" -]); - -const EVALUATOR_TOOLS = new Set([ - "evaluate_run" -]); - -const EVALUATION_READ_TOOLS = new Set([ - "list_evaluations", - "get_evaluation_report" -]); - function nowIso(): string { return new Date().toISOString(); } @@ -2256,13 +1784,6 @@ function safeObject(value: unknown): Record { return isRecord(value) ? value : {}; } -function isRunWorkerStep(step: { stepKey?: unknown; metadata?: unknown }): boolean { - if (step.stepKey === "__planner__") return false; - const metadata = isRecord(step.metadata) ? step.metadata : {}; - if (metadata.systemManaged === true || metadata.plannerLaunchTracker === true) return false; - return !isDisplayOnlyTaskStep(step); -} - /** * Recursively fix JSON Schema issues that strict providers (OpenAI) reject: * - arrays missing `items` → default to `items: {}` @@ -2504,10 +2025,6 @@ export function resolveComputerUseOwners(session: SessionState, toolArgs: Record else if (rawKind === "pr") normalizedKind = "github_pr"; switch (normalizedKind) { case "lane": - case "mission": - case "orchestrator_run": - case "orchestrator_step": - case "orchestrator_attempt": case "chat_session": case "automation_run": case "github_pr": @@ -2517,9 +2034,7 @@ export function resolveComputerUseOwners(session: SessionState, toolArgs: Record ownerId, normalizedKind === "github_pr" || normalizedKind === "linear_issue" ? "published_to" - : normalizedKind === "orchestrator_attempt" - ? "produced_by" - : "attached_to", + : "attached_to", ); break; default: @@ -2528,10 +2043,6 @@ export function resolveComputerUseOwners(session: SessionState, toolArgs: Record }; addExplicitOwner(); - add("mission", session.identity.missionId); - add("orchestrator_run", session.identity.runId); - add("orchestrator_step", session.identity.stepId); - add("orchestrator_attempt", session.identity.attemptId, "produced_by"); add("lane", asOptionalTrimmedString(toolArgs.laneId)); const explicitChatSessionId = asOptionalTrimmedString(toolArgs.chatSessionId); if (explicitChatSessionId) { @@ -2540,8 +2051,7 @@ export function resolveComputerUseOwners(session: SessionState, toolArgs: Record add("chat_session", session.identity.chatSessionId); } else { const looksLikeStandaloneChat = - !session.identity.missionId - && !session.identity.runId + !session.identity.runId && !session.identity.stepId && session.identity.role !== "orchestrator" && session.identity.role !== "evaluator"; @@ -2901,21 +2411,9 @@ function buildAdeInlineGuidanceForLane(laneWorktreePath: string | null | undefin } function resolveRunContextLaneId(runtime: AdeRuntime, callerCtx: CallerContext): string | null { - const runId = asOptionalTrimmedString(callerCtx.runId); - if (!runId) return null; - - const graph = getRunGraphSafe(runtime, runId); - if (graph) { - const inferredWorkerId = inferWorkerIdFromCaller(graph, callerCtx); - const step = resolveStepFromGraph(graph, callerCtx.stepId, inferredWorkerId); - const stepLaneId = asOptionalTrimmedString(step?.laneId); - if (stepLaneId) return stepLaneId; - } - - const missionId = asOptionalTrimmedString(callerCtx.missionId) ?? resolveMissionIdForRun(runtime, runId); - if (!missionId) return null; - const mission = runtime.missionService.get(missionId) as Record | null; - return asOptionalTrimmedString(mission?.laneId) ?? asOptionalTrimmedString(mission?.lane_id); + void runtime; + void callerCtx; + return null; } function resolveRequestedOrSessionLaneId( @@ -3086,8 +2584,6 @@ async function runCtoOperatorBridgeTool( sessionService: runtime.sessionService, resolveExecutionLane: async ({ requestedLaneId }) => requestedLaneId?.trim() || defaultLaneId, laneService: runtime.laneService, - missionService: runtime.missionService, - aiOrchestratorService: runtime.aiOrchestratorService, workerAgentService: runtime.workerAgentService, linearDispatcherService: runtime.linearDispatcherService ?? null, flowPolicyService: runtime.flowPolicyService ?? null, @@ -3244,7 +2740,7 @@ function resolveSpawnContextFile(args: { const payload = { schema: "ade.agent.spawnContext.v1", generatedAt: nowIso(), - mission: { + runContext: { runId: args.runId, stepId: args.stepId, attemptId: args.attemptId @@ -3296,7 +2792,7 @@ function mapLaneSummary(lane: Record): Record /** * Caller context resolved from environment variables. - * Workers spawned by the orchestrator have ADE_MISSION_ID, ADE_RUN_ID, etc. + * Worker-like callers can provide ADE_RUN_ID/ADE_STEP_ID/ADE_ATTEMPT_ID * set in their environment. These provide automatic identity and context defaults. */ type CallerContext = { @@ -3304,7 +2800,6 @@ type CallerContext = { role: SessionIdentity["role"] | null; chatSessionId: string | null; standaloneChatSession: boolean; - missionId: string | null; runId: string | null; stepId: string | null; attemptId: string | null; @@ -3314,7 +2809,6 @@ type CallerContext = { function resolveEnvCallerContext(): CallerContext { const envRole = normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE); const envChatSessionId = process.env.ADE_CHAT_SESSION_ID?.trim() || null; - const envMissionId = process.env.ADE_MISSION_ID?.trim() || null; const envRunId = process.env.ADE_RUN_ID?.trim() || null; const envStepId = process.env.ADE_STEP_ID?.trim() || null; const envAttemptId = process.env.ADE_ATTEMPT_ID?.trim() || null; @@ -3322,8 +2816,7 @@ function resolveEnvCallerContext(): CallerContext { callerId: envChatSessionId ?? envAttemptId ?? null, role: envRole, chatSessionId: envChatSessionId, - standaloneChatSession: Boolean(envChatSessionId) && !envMissionId && !envRunId && !envStepId && !envAttemptId, - missionId: envMissionId, + standaloneChatSession: Boolean(envChatSessionId) && !envRunId && !envStepId && !envAttemptId, runId: envRunId, stepId: envStepId, attemptId: envAttemptId, @@ -3339,7 +2832,6 @@ function resolveCallerContext(session?: SessionState): CallerContext { role: session.identity.role ?? envContext.role, chatSessionId: session.identity.chatSessionId ?? envContext.chatSessionId, standaloneChatSession: session.identity.standaloneChatSession, - missionId: session.identity.missionId ?? envContext.missionId, runId: session.identity.runId ?? envContext.runId, stepId: session.identity.stepId ?? envContext.stepId, attemptId: session.identity.attemptId ?? envContext.attemptId, @@ -3360,10 +2852,6 @@ async function resolveEffectiveCallerContext( ): Promise { const callerCtx = { ...resolveCallerContext(session) }; - if (!callerCtx.missionId && callerCtx.runId) { - callerCtx.missionId = resolveMissionIdForRun(runtime, callerCtx.runId); - } - if (!callerCtx.ownerId && callerCtx.chatSessionId && runtime.agentChatService?.getSessionSummary) { try { const summary = await runtime.agentChatService.getSessionSummary(callerCtx.chatSessionId); @@ -3384,20 +2872,6 @@ function isToolHiddenForStandaloneChat(name: string, callerCtx: CallerContext): return isStandaloneChatCaller(callerCtx) && STANDALONE_CHAT_HIDDEN_TOOL_NAMES.has(name); } -function canCallerAccessCoordinatorTool(name: string, callerCtx: CallerContext): boolean { - if (!COORDINATOR_TOOL_NAMES.has(name)) return true; - if (isToolHiddenForStandaloneChat(name, callerCtx)) return false; - if (callerCtx.role === "orchestrator") return true; - if (callerCtx.role === "agent" && AGENT_VISIBLE_COORDINATOR_TOOL_NAMES.has(name)) return true; - if ( - AGENT_VISIBLE_COORDINATOR_TOOL_NAMES.has(name) - && (callerCtx.attemptId || callerCtx.stepId || callerCtx.runId || callerCtx.missionId) - ) { - return true; - } - return false; -} - function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { return callerCtx.role === "cto" || callerCtx.role === "orchestrator" @@ -3441,24 +2915,15 @@ async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionStat && (macosVmAllowed || !MACOS_VM_TOOL_NAMES.has(tool.name)) ); const visibleBaseTools = TOOL_SPECS.filter(keepVisibleTool); - const visibleCoordinatorTools = COORDINATOR_TOOL_SPECS.filter(keepVisibleTool); - const allVisibleTools = (() => { - if (callerCtx.role === "external" || !callerCtx.role) { - return visibleBaseTools; - } - if (callerCtx.role === "agent") { - return [...visibleBaseTools, ...AGENT_VISIBLE_COORDINATOR_TOOL_SPECS]; - } - if (callerCtx.role === "cto") { - return [...visibleBaseTools, ...CTO_OPERATOR_TOOL_SPECS, ...CTO_LINEAR_SYNC_TOOL_SPECS]; - } - return [...visibleBaseTools, ...visibleCoordinatorTools]; - })(); + const allVisibleTools = callerCtx.role === "cto" + ? [...visibleBaseTools, ...CTO_OPERATOR_TOOL_SPECS, ...CTO_LINEAR_SYNC_TOOL_SPECS] + : visibleBaseTools; return allVisibleTools.filter((tool) => !isToolHiddenForStandaloneChat(tool.name, callerCtx)); } function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionIdentity { + void runtime; const data = safeObject(params); const identity = safeObject(data.identity); const envContext = resolveEnvCallerContext(); @@ -3471,19 +2936,8 @@ function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionI const resolvedRunId = envContext.runId ?? asOptionalTrimmedString(identity.runId); const resolvedStepId = envContext.stepId ?? asOptionalTrimmedString(identity.stepId); const resolvedAttemptId = envContext.attemptId ?? asOptionalTrimmedString(identity.attemptId); - const requestedMissionId = asOptionalTrimmedString(identity.missionId); - const resolvedMissionId = - envContext.missionId - ?? (resolvedRunId ? resolveMissionIdForRun(runtime, resolvedRunId) : null); - if (requestedMissionId && resolvedMissionId && requestedMissionId !== resolvedMissionId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "identity.missionId does not match the server-authorized run context", - ); - } const standaloneChatSession = Boolean(resolvedChatSessionId) - && !envContext.missionId && !resolvedRunId && !resolvedStepId && !resolvedAttemptId; @@ -3493,7 +2947,6 @@ function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionI role: validRole, chatSessionId: resolvedChatSessionId, standaloneChatSession, - missionId: resolvedMissionId ?? requestedMissionId ?? null, runId: resolvedRunId, stepId: resolvedStepId, attemptId: resolvedAttemptId, @@ -3676,953 +3129,6 @@ function ensureAskUserAllowed(session: SessionState): void { GLOBAL_ASK_USER_RATE_LIMIT.events.push(now); } -type CoordinatorToolCacheEntry = { - missionId: string; - tools: Record; -}; - -const coordinatorToolCacheByRuntime = new WeakMap>(); - -function resolveMissionIdForRun(runtime: AdeRuntime, runId: string): string | null { - const graphMissionId = (() => { - try { - const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const runRecord = safeObject(graph.run); - return asOptionalTrimmedString(runRecord.missionId); - } catch { - return null; - } - })(); - if (graphMissionId) return graphMissionId; - const row = runtime.db.get<{ mission_id: string | null }>( - ` - select mission_id - from orchestrator_runs - where id = ? - limit 1 - `, - [runId] - ); - return asOptionalTrimmedString(row?.mission_id); -} - -function resolveRunIdForMission(runtime: AdeRuntime, missionId: string): string | null { - const row = runtime.db.get<{ id: string | null }>( - ` - select id - from orchestrator_runs - where mission_id = ? - order by - case status - when 'active' then 0 - when 'bootstrapping' then 1 - when 'queued' then 2 - when 'paused' then 3 - else 4 - end, - datetime(updated_at) desc, - datetime(created_at) desc - limit 1 - `, - [missionId] - ); - return asOptionalTrimmedString(row?.id); -} - -function getCoordinatorToolSet(args: { - runtime: AdeRuntime; - runId: string; - missionId: string; -}): Record { - let runtimeCache = coordinatorToolCacheByRuntime.get(args.runtime); - if (!runtimeCache) { - runtimeCache = new Map(); - coordinatorToolCacheByRuntime.set(args.runtime, runtimeCache); - } - const cached = runtimeCache.get(args.runId); - if (cached && cached.missionId === args.missionId) { - return cached.tools; - } - - const missionRecord = safeObject(args.runtime.missionService.get(args.missionId)); - const missionLaneId = asOptionalTrimmedString(missionRecord.laneId ?? missionRecord.lane_id); - const toolSet = createCoordinatorToolSet({ - orchestratorService: args.runtime.orchestratorService, - missionService: args.runtime.missionService, - runId: args.runId, - missionId: args.missionId, - logger: args.runtime.logger, - db: args.runtime.db, - projectRoot: args.runtime.projectRoot, - workspaceRoot: (() => { - const laneWorkspaceRoot = resolveLaneWorktreePath(args.runtime, missionLaneId); - if (missionLaneId && !laneWorkspaceRoot) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Mission lane '${missionLaneId}' does not have an available worktree.`, - ); - } - return laneWorkspaceRoot - ?? (typeof args.runtime.workspaceRoot === "string" && args.runtime.workspaceRoot.trim().length > 0 - ? args.runtime.workspaceRoot.trim() - : args.runtime.projectRoot); - })(), - missionLaneId: missionLaneId ?? undefined, - onRunFinalize: ({ runId }) => { - args.runtime.aiOrchestratorService.finalizeRun({ runId, force: true }); - }, - onDagMutation: (event) => { - args.runtime.eventBuffer.push({ - timestamp: nowIso(), - category: "dag_mutation", - payload: event as unknown as Record - }); - } - }); - const normalizedToolSet = toolSet as unknown as Record; - runtimeCache.set(args.runId, { - missionId: args.missionId, - tools: normalizedToolSet - }); - return normalizedToolSet; -} - -type NativeCallerRegistration = { - memberId: string; - parentWorkerId: string; - parentStepId: string; - sourceAttemptId: string; -}; - -function getTeamRuntimeContext(runtime: AdeRuntime): import("../../desktop/src/main/services/orchestrator/orchestratorContext").OrchestratorContext { - return { - db: runtime.db, - logger: runtime.logger, - } as import("../../desktop/src/main/services/orchestrator/orchestratorContext").OrchestratorContext; -} - -function getRunGraphSafe(runtime: AdeRuntime, runId: string): Record | null { - try { - return runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }) as unknown as Record; - } catch { - return null; - } -} - -function getTeamMembersForRunSafe(runtime: AdeRuntime, runId: string): Array> { - const aiService = runtime.aiOrchestratorService as unknown as { getTeamMembers?: (args: { runId: string }) => unknown }; - if (typeof aiService.getTeamMembers === "function") { - try { - const members = aiService.getTeamMembers({ runId }); - if (Array.isArray(members)) return members as Array>; - } catch { - // Fall through to DB-backed path. - } - } - return getTeamMembersForRun(getTeamRuntimeContext(runtime), runId) as unknown as Array>; -} - -function resolveStepFromGraph(graph: Record, stepId: string | null, stepKey: string | null): Record | null { - const steps = Array.isArray(graph.steps) ? (graph.steps as Array>) : []; - if (stepId) { - const byId = steps.find((step) => asOptionalTrimmedString(step.id) === stepId); - if (byId) return byId; - } - if (stepKey) { - const byKey = steps.find((step) => asOptionalTrimmedString(step.stepKey) === stepKey); - if (byKey) return byKey; - } - return null; -} - -function titleCaseWords(raw: string): string { - return raw - .split(/[\s_-]+/) - .map((token) => token ? `${token.charAt(0).toUpperCase()}${token.slice(1).toLowerCase()}` : "") - .join(" ") - .trim(); -} - -function deriveQuestionOwnerFromPhase(args: { - phaseKey?: string | null; - phaseName?: string | null; -}): { ownerKind: string; ownerLabel: string } { - const normalizedPhaseKey = asOptionalTrimmedString(args.phaseKey)?.toLowerCase() ?? ""; - const normalizedPhaseName = asOptionalTrimmedString(args.phaseName)?.toLowerCase() ?? ""; - const normalized = normalizedPhaseKey || normalizedPhaseName; - if (normalized === "planning") { - return { ownerKind: "planner", ownerLabel: "Planner question" }; - } - if (normalized === "development") { - return { ownerKind: "developer", ownerLabel: "Developer question" }; - } - if (normalized === "validation") { - return { ownerKind: "validator", ownerLabel: "Validator question" }; - } - if (normalized === "testing") { - return { ownerKind: "tester", ownerLabel: "Tester question" }; - } - const humanPhase = asOptionalTrimmedString(args.phaseName) ?? asOptionalTrimmedString(args.phaseKey); - if (humanPhase) { - return { - ownerKind: normalized || "phase_worker", - ownerLabel: `${titleCaseWords(humanPhase)} question`, - }; - } - return { ownerKind: "worker", ownerLabel: "Worker question" }; -} - -function getAgentAskUserPolicy(args: { - runtime: AdeRuntime; - callerCtx: CallerContext; -}): { - stepId: string | null; - stepKey: string | null; - phase: string | null; - phaseName: string | null; - enabled: boolean; - maxQuestions: number | null; - ownerKind: string; - ownerLabel: string; - existingQuestions: number; -} | null { - if (args.callerCtx.role !== "agent" || !args.callerCtx.runId) return null; - const graph = getRunGraphSafe(args.runtime, args.callerCtx.runId); - if (!graph) return null; - - const inferredWorkerId = inferWorkerIdFromCaller(graph, args.callerCtx); - const step = resolveStepFromGraph(graph, args.callerCtx.stepId, inferredWorkerId); - if (!step) return null; - - const metadata = safeObject(step.metadata); - const phase = asOptionalTrimmedString(metadata.phaseKey); - const phaseName = asOptionalTrimmedString(metadata.phaseName); - const phaseAskQuestions = safeObject(metadata.phaseAskQuestions); - const defaultEnabled = (phase?.toLowerCase() ?? phaseName?.toLowerCase() ?? "") === "planning"; - const enabled = typeof phaseAskQuestions.enabled === "boolean" - ? phaseAskQuestions.enabled - : defaultEnabled; - const rawMaxQuestions = Number(phaseAskQuestions.maxQuestions ?? Number.NaN); - const maxQuestions = enabled - ? Number.isFinite(rawMaxQuestions) - ? Math.max(1, Math.min(10, Math.floor(rawMaxQuestions))) - : 5 - : null; - const mission = args.callerCtx.missionId ? args.runtime.missionService.get(args.callerCtx.missionId) : null; - const stepId = asOptionalTrimmedString(step.id); - const stepKey = asOptionalTrimmedString(step.stepKey); - const existingQuestions = (mission?.interventions ?? []).filter((entry) => { - if (entry.interventionType !== "manual_input") return false; - const metadata = safeObject(entry.metadata); - if (asOptionalTrimmedString(metadata.source) !== "ask_user") return false; - if (asOptionalTrimmedString(metadata.runId) !== args.callerCtx.runId) return false; - const entryStepId = asOptionalTrimmedString(metadata.stepId); - const entryStepKey = asOptionalTrimmedString(metadata.stepKey); - if (stepId && entryStepId === stepId) return true; - if (stepKey && entryStepKey === stepKey) return true; - const entryPhase = asOptionalTrimmedString(metadata.phase)?.toLowerCase() ?? ""; - const normalizedPhase = (phase ?? phaseName ?? "").toLowerCase(); - return normalizedPhase.length > 0 && entryPhase === normalizedPhase; - }).length ?? 0; - - const owner = deriveQuestionOwnerFromPhase({ phaseKey: phase, phaseName }); - return { - stepId, - stepKey, - phase, - phaseName, - enabled, - maxQuestions, - ownerKind: owner.ownerKind, - ownerLabel: owner.ownerLabel, - existingQuestions, - }; -} - -function inferWorkerIdFromCaller(graph: Record, callerCtx: CallerContext): string | null { - const directStep = resolveStepFromGraph(graph, callerCtx.stepId, null); - const directKey = asOptionalTrimmedString(directStep?.stepKey); - if (directKey) return directKey; - - const attempts = Array.isArray(graph.attempts) ? (graph.attempts as Array>) : []; - const attemptId = asOptionalTrimmedString(callerCtx.attemptId) ?? asOptionalTrimmedString(callerCtx.callerId); - if (!attemptId) return null; - const attempt = attempts.find((entry) => asOptionalTrimmedString(entry.id) === attemptId); - const stepId = asOptionalTrimmedString(attempt?.stepId); - if (!stepId) return null; - const step = resolveStepFromGraph(graph, stepId, null); - return asOptionalTrimmedString(step?.stepKey); -} - -function normalizeWorkerOutcome(raw: unknown): "succeeded" | "failed" | "partial" { - const normalized = asOptionalTrimmedString(raw)?.toLowerCase() ?? ""; - if (normalized === "succeeded" || normalized === "success" || normalized === "pass" || normalized === "passed" || normalized === "done" || normalized === "completed") { - return "succeeded"; - } - if (normalized === "failed" || normalized === "fail" || normalized === "error") { - return "failed"; - } - return "partial"; -} - -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; - 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 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") { - 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") { - statusCounts.failed += 1; - } else if (!result && /\b(?:fail|failed|failure|error)\b/i.test(text)) { - statusCounts.failed += 1; - } else { - statusCounts.skipped += 1; - } - } - 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: { - name: string; - toolArgs: Record; - callerCtx: CallerContext; - graph: Record | null; -}): Record { - const normalized = { ...args.toolArgs }; - const inferredWorkerId = args.graph ? inferWorkerIdFromCaller(args.graph, args.callerCtx) : null; - const stepKeyAlias = asOptionalTrimmedString(normalized.stepKey); - const explicitWorkerId = asOptionalTrimmedString(normalized.workerId); - - if (!explicitWorkerId && stepKeyAlias) { - normalized.workerId = stepKeyAlias; - } else if (!explicitWorkerId && inferredWorkerId) { - normalized.workerId = inferredWorkerId; - } - - if (args.name === "report_status") { - const summary = asOptionalTrimmedString(normalized.summary); - if (!asOptionalTrimmedString(normalized.nextAction) && summary) { - normalized.nextAction = summary; - } - if (!asOptionalTrimmedString(normalized.details) && summary) { - normalized.details = summary; - } - if (!Array.isArray(normalized.blockers) && typeof normalized.blockers === "string") { - normalized.blockers = [normalized.blockers]; - } - const rawProgress = Number(normalized.progressPct ?? Number.NaN); - if (!Number.isFinite(rawProgress)) { - const status = asOptionalTrimmedString(normalized.status)?.toLowerCase() ?? ""; - if (status === "succeeded" || status === "success" || status === "completed" || status === "done") { - normalized.progressPct = 100; - } else if (status === "running" || status === "working" || status === "in_progress" || status === "in-progress") { - normalized.progressPct = 50; - } else if (status === "blocked" || status === "waiting") { - normalized.progressPct = 25; - } else { - normalized.progressPct = 0; - } - } - } else if (args.name === "report_result") { - normalized.outcome = normalizeWorkerOutcome(normalized.outcome); - const summarizedTests = summarizeLegacyTestsRun(normalized.testsRun); - if (summarizedTests) { - normalized.testsRun = summarizedTests; - } - } else if (args.name === "report_validation") { - if (!asOptionalTrimmedString(normalized.validatorWorkerId) && inferredWorkerId) { - normalized.validatorWorkerId = inferredWorkerId; - } - if (!Array.isArray(normalized.findings)) { - normalized.findings = []; - } - if (!Array.isArray(normalized.remediationInstructions)) { - const notes = Array.isArray(normalized.notes) - ? normalized.notes.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - normalized.remediationInstructions = notes; - } - } - - return normalized; -} - -function normalizeAgentDelegationToolArgs(args: { - name: string; - toolArgs: Record; - callerCtx: CallerContext; - graph: Record | null; -}): Record { - const normalized = { ...args.toolArgs }; - if (args.callerCtx.role !== "agent") return normalized; - if (args.name === "message_worker") { - if (!args.graph) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "Agent caller cannot use 'message_worker' without an active run graph." - ); - } - const ownedWorkerId = inferWorkerIdFromCaller(args.graph, args.callerCtx); - if (!ownedWorkerId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "Agent caller cannot use 'message_worker' without an active worker context." - ); - } - const requestedFromWorkerId = asOptionalTrimmedString(normalized.fromWorkerId); - if (requestedFromWorkerId && requestedFromWorkerId !== ownedWorkerId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Agent caller may only send messages from its own worker '${ownedWorkerId}'.` - ); - } - normalized.fromWorkerId = ownedWorkerId; - return normalized; - } - if (args.name !== "delegate_to_subagent" && args.name !== "delegate_parallel") return normalized; - if (!args.graph) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Agent caller cannot use '${args.name}' without an active run graph.` - ); - } - - const ownedWorkerId = inferWorkerIdFromCaller(args.graph, args.callerCtx); - if (!ownedWorkerId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Agent caller cannot use '${args.name}' without an active parent worker context.` - ); - } - - const requestedParentWorkerId = asOptionalTrimmedString(normalized.parentWorkerId); - if (requestedParentWorkerId && requestedParentWorkerId !== ownedWorkerId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Agent caller may only delegate beneath its own worker '${ownedWorkerId}'.` - ); - } - - normalized.parentWorkerId = ownedWorkerId; - return normalized; -} - -function resolveParentAttemptIdFromGraph(graph: Record, parentWorkerId: string): string | null { - const steps = Array.isArray(graph.steps) ? (graph.steps as Array>) : []; - const attempts = Array.isArray(graph.attempts) ? (graph.attempts as Array>) : []; - const parentStep = steps.find((step) => asOptionalTrimmedString(step.stepKey) === parentWorkerId); - const parentStepId = asOptionalTrimmedString(parentStep?.id); - if (!parentStepId) return null; - const parentAttempts = attempts.filter((attempt) => asOptionalTrimmedString(attempt.stepId) === parentStepId); - if (!parentAttempts.length) return null; - const running = parentAttempts.find((attempt) => asOptionalTrimmedString(attempt.status) === "running"); - if (running) return asOptionalTrimmedString(running.id); - const sorted = [...parentAttempts].sort((left, right) => { - const leftTs = Date.parse(asOptionalTrimmedString(left.completedAt) ?? asOptionalTrimmedString(left.createdAt) ?? ""); - const rightTs = Date.parse(asOptionalTrimmedString(right.completedAt) ?? asOptionalTrimmedString(right.createdAt) ?? ""); - return (Number.isFinite(rightTs) ? rightTs : 0) - (Number.isFinite(leftTs) ? leftTs : 0); - }); - return asOptionalTrimmedString(sorted[0]?.id); -} - -function resolveAttemptIdForWorkerRef(graph: Record, workerRef: string | null | undefined): string | null { - const ref = asOptionalTrimmedString(workerRef); - if (!ref) return null; - const attempts = Array.isArray(graph.attempts) ? (graph.attempts as Array>) : []; - const step = resolveStepFromGraph(graph, ref, ref); - const stepId = asOptionalTrimmedString(step?.id); - if (!stepId) return null; - const stepAttempts = attempts.filter((attempt) => asOptionalTrimmedString(attempt.stepId) === stepId); - if (!stepAttempts.length) return null; - const running = stepAttempts.find((attempt) => asOptionalTrimmedString(attempt.status) === "running"); - if (running) return asOptionalTrimmedString(running.id); - const lastAttemptId = asOptionalTrimmedString(step?.lastAttemptId); - if (lastAttemptId && stepAttempts.some((attempt) => asOptionalTrimmedString(attempt.id) === lastAttemptId)) { - return lastAttemptId; - } - const sorted = [...stepAttempts].sort((left, right) => { - const leftTs = Date.parse(asOptionalTrimmedString(left.completedAt) ?? asOptionalTrimmedString(left.createdAt) ?? ""); - const rightTs = Date.parse(asOptionalTrimmedString(right.completedAt) ?? asOptionalTrimmedString(right.createdAt) ?? ""); - return (Number.isFinite(rightTs) ? rightTs : 0) - (Number.isFinite(leftTs) ? leftTs : 0); - }); - return asOptionalTrimmedString(sorted[0]?.id); -} - -function inferParallelismCap(graph: Record): number { - const run = safeObject(graph.run); - const runMetadata = safeObject(run.metadata); - const autopilot = safeObject(runMetadata.autopilot); - const raw = Number(autopilot.parallelismCap ?? runMetadata.maxParallelWorkers ?? Number.NaN); - if (Number.isFinite(raw) && raw > 0) { - return Math.max(1, Math.min(32, Math.floor(raw))); - } - return 4; -} - -function ensureNativeTeammateRegistration(args: { - runtime: AdeRuntime; - runId: string; - missionId: string; - callerCtx: CallerContext; - toolName: string; - toolArgs: Record; -}): NativeCallerRegistration | null { - if (args.toolName !== "report_status" && args.toolName !== "report_result") return null; - if (args.callerCtx.role !== "agent") return null; - const graph = getRunGraphSafe(args.runtime, args.runId); - if (!graph) return null; - - const callerId = asOptionalTrimmedString(args.callerCtx.callerId) ?? asOptionalTrimmedString(args.callerCtx.attemptId); - if (!callerId || callerId === "unknown") return null; - - const attempts = Array.isArray(graph.attempts) ? (graph.attempts as Array>) : []; - const steps = Array.isArray(graph.steps) ? (graph.steps as Array>) : []; - const knownAttemptIds = new Set( - attempts - .map((attempt) => asOptionalTrimmedString(attempt.id)) - .filter((entry): entry is string => Boolean(entry)) - ); - const knownStepKeys = new Set( - steps - .map((step) => asOptionalTrimmedString(step.stepKey)) - .filter((entry): entry is string => Boolean(entry)) - ); - const existingMembers = getTeamMembersForRunSafe(args.runtime, args.runId); - const knownTeamMemberIds = new Set( - existingMembers - .map((member) => asOptionalTrimmedString(member.id)) - .filter((entry): entry is string => Boolean(entry)) - ); - if (knownAttemptIds.has(callerId) || knownStepKeys.has(callerId) || knownTeamMemberIds.has(callerId)) { - return null; - } - - let parentStep: Record | null = null; - if (args.callerCtx.stepId) { - parentStep = resolveStepFromGraph(graph, args.callerCtx.stepId, null); - } - if (!parentStep && args.callerCtx.attemptId) { - const parentAttempt = attempts.find((attempt) => asOptionalTrimmedString(attempt.id) === args.callerCtx.attemptId); - const parentStepId = asOptionalTrimmedString(parentAttempt?.stepId); - if (parentStepId) { - parentStep = resolveStepFromGraph(graph, parentStepId, null); - } - } - if (!parentStep) { - const fallbackWorkerId = asOptionalTrimmedString(args.toolArgs.workerId); - if (fallbackWorkerId) { - parentStep = resolveStepFromGraph(graph, null, fallbackWorkerId); - } - } - if (!parentStep) return null; - - const parentWorkerId = asOptionalTrimmedString(parentStep.stepKey); - const parentStepId = asOptionalTrimmedString(parentStep.id); - if (!parentWorkerId || !parentStepId) return null; - - const nativeMemberId = `claude-native:${args.runId}:${createHash("sha1").update(callerId).digest("hex").slice(0, 16)}`; - const existing = existingMembers.find((member) => asOptionalTrimmedString(member.id) === nativeMemberId) ?? null; - const cap = inferParallelismCap(graph); - const activeForParent = existingMembers.filter((member) => { - const metadata = safeObject(member.metadata); - const source = asOptionalTrimmedString(member.source) ?? asOptionalTrimmedString(metadata.source); - const memberParent = asOptionalTrimmedString(member.parentWorkerId) ?? asOptionalTrimmedString(metadata.parentWorkerId); - const status = asOptionalTrimmedString(member.status) ?? "unknown"; - return source === "claude-native" - && memberParent === parentWorkerId - && status !== "terminated" - && status !== "failed"; - }).length; - if (!existing && activeForParent >= cap) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Native teammate allocation cap exceeded for parent '${parentWorkerId}' (${activeForParent}/${cap}).` - ); - } - - const parentMetadata = safeObject(parentStep.metadata); - const inferredModel = asOptionalTrimmedString(parentMetadata.modelId) ?? "claude-native"; - const now = nowIso(); - if (existing) { - updateTeamMemberStatus(getTeamRuntimeContext(args.runtime), nativeMemberId, { - status: "active" - }); - } else { - registerTeamMember(getTeamRuntimeContext(args.runtime), { - id: nativeMemberId, - runId: args.runId, - missionId: args.missionId, - provider: "claude", - model: inferredModel, - role: "teammate", - source: "claude-native", - parentWorkerId, - sessionId: null, - status: "active", - claimedTaskIds: [], - metadata: { - source: "claude-native", - parentWorkerId, - parentStepId, - parentAttemptId: args.callerCtx.attemptId ?? null, - nativeCallerId: callerId, - }, - createdAt: now, - updatedAt: now, - }); - } - - if (!asOptionalTrimmedString(args.toolArgs.workerId)) { - args.toolArgs.workerId = parentWorkerId; - } - - return { - memberId: nativeMemberId, - parentWorkerId, - parentStepId, - sourceAttemptId: nativeMemberId, - }; -} - -async function maybeSendInterAgentMessage(args: { - runtime: AdeRuntime; - missionId: string; - fromAttemptId: string; - toAttemptId: string; - content: string; - metadata: Record; -}): Promise { - const sender = (args.runtime.aiOrchestratorService as unknown as { - sendAgentMessage?: (msg: { - missionId: string; - fromAttemptId: string; - toAttemptId: string; - content: string; - metadata?: Record | null; - }) => unknown; - }).sendAgentMessage; - if (typeof sender !== "function") return; - await Promise.resolve(sender({ - missionId: args.missionId, - fromAttemptId: args.fromAttemptId, - toAttemptId: args.toAttemptId, - content: args.content, - metadata: args.metadata, - })); -} - -async function maybeRecordMessageWorkerHandoffForPolling(args: { - runtime: AdeRuntime; - missionId: string; - graph: Record | null; - callerCtx: CallerContext; - toolArgs: Record; -}): Promise<{ - fromAttemptId: string; - toAttemptId: string; - fromWorkerId: string; - toWorkerId: string; -} | null> { - if (!args.graph) return null; - const fromWorkerId = asOptionalTrimmedString(args.toolArgs.fromWorkerId) - ?? inferWorkerIdFromCaller(args.graph, args.callerCtx); - const toWorkerId = asOptionalTrimmedString(args.toolArgs.toWorkerId); - const rawContent = args.toolArgs.content; - const content = typeof rawContent === "string" - ? rawContent - : rawContent == null - ? "" - : String(rawContent); - if (!fromWorkerId || !toWorkerId || !content.trim().length) return null; - - const fromAttemptId = - asOptionalTrimmedString(args.callerCtx.attemptId) - ?? resolveAttemptIdForWorkerRef(args.graph, fromWorkerId); - const toAttemptId = resolveAttemptIdForWorkerRef(args.graph, toWorkerId); - if (!fromAttemptId || !toAttemptId || fromAttemptId === toAttemptId) return null; - - await maybeSendInterAgentMessage({ - runtime: args.runtime, - missionId: args.missionId, - fromAttemptId, - toAttemptId, - content, - metadata: { - source: "message_worker", - fromWorkerId, - toWorkerId, - priority: asOptionalTrimmedString(args.toolArgs.priority) ?? "normal", - queuedForPolling: true, - }, - }); - - return { - fromAttemptId, - toAttemptId, - fromWorkerId, - toWorkerId, - }; -} - -async function postProcessCoordinatorToolResult(args: { - runtime: AdeRuntime; - toolName: string; - runId: string; - missionId: string; - callerCtx: CallerContext; - result: Record; - nativeRegistration: NativeCallerRegistration | null; -}): Promise { - if (args.toolName === "report_status") { - const report = safeObject(args.result.report); - const workerId = asOptionalTrimmedString(report.workerId); - const stepId = asOptionalTrimmedString(report.stepId); - const progressPct = Number(report.progressPct ?? Number.NaN); - const nextAction = asOptionalTrimmedString(report.nextAction) ?? "status update"; - const blockers = Array.isArray(report.blockers) - ? report.blockers.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - - args.runtime.eventBuffer.push({ - timestamp: nowIso(), - category: "runtime", - payload: { - type: "worker_status_reported", - runId: args.runId, - stepId, - attemptId: args.callerCtx.attemptId ?? null, - reason: "report_status", - detail: report, - } - }); - - const graph = getRunGraphSafe(args.runtime, args.runId); - if (!graph) return; - const step = resolveStepFromGraph(graph, stepId, workerId); - const stepMetadata = safeObject(step?.metadata); - const parentWorkerId = - args.nativeRegistration?.parentWorkerId - ?? asOptionalTrimmedString(stepMetadata.parentWorkerId); - if (!parentWorkerId) return; - const parentAttemptId = resolveParentAttemptIdFromGraph(graph, parentWorkerId); - if (!parentAttemptId) return; - const fromAttemptId = - args.nativeRegistration?.sourceAttemptId - ?? asOptionalTrimmedString(args.callerCtx.attemptId) - ?? workerId; - if (!fromAttemptId || fromAttemptId === parentAttemptId) return; - - const workerLabel = asOptionalTrimmedString(step?.title) ?? workerId ?? "sub-agent"; - const progressLabel = Number.isFinite(progressPct) ? `${Math.max(0, Math.min(100, Math.round(progressPct)))}%` : "progress"; - const blockerSuffix = blockers.length > 0 ? ` Blockers: ${blockers.join("; ")}` : ""; - const content = `[sub-agent:${workerLabel}] ${progressLabel} — ${nextAction}.${blockerSuffix}`; - - await maybeSendInterAgentMessage({ - runtime: args.runtime, - missionId: args.missionId, - fromAttemptId, - toAttemptId: parentAttemptId, - content, - metadata: { - source: "subagent_status_rollup", - parentWorkerId, - workerId: workerId ?? null, - isNative: Boolean(args.nativeRegistration), - }, - }); - return; - } - - if (args.toolName === "report_result" && args.nativeRegistration) { - const report = safeObject(args.result.report); - const graph = getRunGraphSafe(args.runtime, args.runId); - if (!graph) return; - const parentAttemptId = resolveParentAttemptIdFromGraph(graph, args.nativeRegistration.parentWorkerId); - if (!parentAttemptId) return; - const outcome = asOptionalTrimmedString(report.outcome) ?? "completed"; - const summary = asOptionalTrimmedString(report.summary) ?? "No summary provided."; - const content = `Sub-agent '${args.nativeRegistration.memberId}' completed (${outcome}): ${summary}`; - await maybeSendInterAgentMessage({ - runtime: args.runtime, - missionId: args.missionId, - fromAttemptId: args.nativeRegistration.sourceAttemptId, - toAttemptId: parentAttemptId, - content, - metadata: { - source: "subagent_result_rollup", - parentWorkerId: args.nativeRegistration.parentWorkerId, - isNative: true, - }, - }); - } -} - -async function runCoordinatorTool(args: { - runtime: AdeRuntime; - name: string; - toolArgs: Record; - callerCtx: CallerContext; -}): Promise> { - const missionIdFromContext = - args.callerCtx.missionId - ?? asOptionalTrimmedString(args.toolArgs.missionId); - const runId = - args.callerCtx.runId - ?? asOptionalTrimmedString(args.toolArgs.runId) - ?? (missionIdFromContext ? resolveRunIdForMission(args.runtime, missionIdFromContext) : null); - if (!runId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Coordinator tool '${args.name}' requires run context. Provide runId or set ADE_RUN_ID.` - ); - } - const missionId = - missionIdFromContext - ?? resolveMissionIdForRun(args.runtime, runId); - if (!missionId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Coordinator tool '${args.name}' requires mission context. Provide missionId or set ADE_MISSION_ID.` - ); - } - - const toolSet = getCoordinatorToolSet({ - runtime: args.runtime, - runId, - missionId - }); - const toolEntry = toolSet[args.name] as { execute?: (input: Record) => Promise } | undefined; - if (!toolEntry?.execute) { - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Coordinator tool not found: ${args.name}`); - } - const graph = getRunGraphSafe(args.runtime, runId); - const normalizedToolArgs = - args.name === "report_status" || args.name === "report_result" || args.name === "report_validation" - ? normalizeCoordinatorWorkerToolArgs({ - name: args.name, - toolArgs: args.toolArgs, - callerCtx: args.callerCtx, - graph, - }) - : { ...args.toolArgs }; - const effectiveToolArgs = normalizeAgentDelegationToolArgs({ - name: args.name, - toolArgs: normalizedToolArgs, - callerCtx: args.callerCtx, - graph, - }); - const nativeRegistration = ensureNativeTeammateRegistration({ - runtime: args.runtime, - runId, - missionId, - callerCtx: args.callerCtx, - toolName: args.name, - toolArgs: effectiveToolArgs, - }); - const output = await toolEntry.execute(effectiveToolArgs); - if (isRecord(output)) { - if (args.name === "message_worker") { - const liveDelivered = output.delivered === true; - if (liveDelivered) { - return { - ...output, - queuedForPolling: false, - }; - } - const queued = await maybeRecordMessageWorkerHandoffForPolling({ - runtime: args.runtime, - missionId, - graph, - callerCtx: args.callerCtx, - toolArgs: effectiveToolArgs, - }); - if (queued) { - return { - ...output, - ok: true, - delivered: false, - method: "thread", - reason: "queued_for_polling", - queuedForPolling: true, - fromAttemptId: queued.fromAttemptId, - toAttemptId: queued.toAttemptId, - fromWorkerId: queued.fromWorkerId, - toWorkerId: queued.toWorkerId, - }; - } - } - if (output.ok === true && (args.name === "report_status" || args.name === "report_result")) { - await postProcessCoordinatorToolResult({ - runtime: args.runtime, - toolName: args.name, - runId, - missionId, - callerCtx: args.callerCtx, - result: output, - nativeRegistration, - }); - } - return output; - } - return { - ok: true, - result: output ?? null - }; -} - - async function runTool(args: { runtime: AdeRuntime; session: SessionState; @@ -5046,21 +3552,15 @@ async function runTool(args: { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); } - if (COORDINATOR_TOOL_NAMES.has(name)) { - if (!canCallerAccessCoordinatorTool(name, callerCtx)) { - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); - } - return await runCoordinatorTool({ runtime, name, toolArgs, callerCtx }); - } - if (name === "list_ade_actions") { const domain = asOptionalTrimmedString(toolArgs.domain) ?? "all"; const services = getAdeActionDomainServices(runtime); const domains = domain === "all" ? (Object.keys(services) as AdeActionDomain[]) : [domain as AdeActionDomain]; + const exposedDomains = domains.filter((entry) => !DISABLED_ADE_ACTION_DOMAINS.has(entry)); const callerIsCto = callerHasRoleAtLeast(callerCtx.role, "cto"); - const actions = domains.flatMap((entry) => { + const actions = exposedDomains.flatMap((entry) => { const service = services[entry]; if (!service) return []; return listAllowedAdeActionNames(entry, service) @@ -5081,6 +3581,9 @@ async function runTool(args: { if (name === "run_ade_action") { const domain = assertNonEmptyString(toolArgs.domain, "domain") as AdeActionDomain; const action = assertNonEmptyString(toolArgs.action, "action"); + if (DISABLED_ADE_ACTION_DOMAINS.has(domain)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Domain '${domain}' is unavailable in this runtime.`); + } const services = getAdeActionDomainServices(runtime); const service = services[domain]; if (!service) { @@ -5113,12 +3616,6 @@ async function runTool(args: { testRunId: typeof record?.id === "string" && domain === "tests" ? record.id : null, chatSessionId: typeof record?.sessionId === "string" ? record.sessionId : null, runId: typeof record?.runId === "string" ? record.runId : null, - missionId: - typeof record?.missionId === "string" - ? record.missionId - : typeof record?.id === "string" && domain === "mission" - ? record.id - : null, }; return { domain, @@ -5260,8 +3757,6 @@ async function runTool(args: { const operationId = asOptionalTrimmedString(toolArgs.operationId); const testRunId = asOptionalTrimmedString(toolArgs.testRunId); const chatSessionId = asOptionalTrimmedString(toolArgs.chatSessionId); - const runId = asOptionalTrimmedString(toolArgs.runId); - const missionId = asOptionalTrimmedString(toolArgs.missionId); const prId = asOptionalTrimmedString(toolArgs.prId); const previousHash = asOptionalTrimmedString(toolArgs.previousHash); const waitForMs = Math.max(0, Math.min(120_000, Math.floor(asNumber(toolArgs.waitForMs, 0)))); @@ -5281,12 +3776,6 @@ async function runTool(args: { if (chatSessionId && runtime.agentChatService) { payload.chatSession = await runtime.agentChatService.getSessionSummary(chatSessionId); } - if (runId) { - payload.runGraph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 150 }); - } - if (missionId) { - payload.mission = runtime.missionService.get(missionId); - } if (prId && runtime.prService) { payload.pr = { health: await runtime.prService.getPrHealth(prId), @@ -5473,14 +3962,9 @@ async function runTool(args: { if (name === "ask_user") { ensureAskUserAllowed(session); - const missionId = asOptionalTrimmedString(toolArgs.missionId) ?? callerCtx.missionId ?? undefined; const title = assertNonEmptyString(toolArgs.title, "title"); const body = assertNonEmptyString(toolArgs.body, "body"); - const requestedAction = asOptionalTrimmedString(toolArgs.requestedAction); - const laneId = asOptionalTrimmedString(toolArgs.laneId); - const phase = asOptionalTrimmedString(toolArgs.phase); const waitForResolutionMs = Math.max(0, Math.floor(asNumber(toolArgs.waitForResolutionMs, 0))); - const pollIntervalMs = Math.max(100, Math.floor(asNumber(toolArgs.pollIntervalMs, 1000))); const structuredQuestions = Array.isArray(toolArgs.questions) ? toolArgs.questions.flatMap((rawQuestion, index) => { if (!rawQuestion || typeof rawQuestion !== "object") return []; @@ -5523,7 +4007,6 @@ async function runTool(args: { }]; }) : undefined; - const askUserPolicy = getAgentAskUserPolicy({ runtime, callerCtx }); const summarizeAskUserDecision = (decision: string, responseText: string | null, answered: boolean): string | null => { const trimmed = typeof responseText === "string" ? responseText.trim() : ""; if (trimmed.length) return trimmed; @@ -5534,182 +4017,77 @@ async function runTool(args: { return "The user did not answer the question."; }; const buildAskUserResult = (args: { - intervention?: unknown; awaitingUserResponse: boolean; blocking: boolean; - outcome: "pending" | "answered" | "declined" | "cancelled" | "timed_out"; + outcome: "answered" | "declined" | "cancelled" | "timed_out"; decision?: string; responseText?: string | null; answers?: Record; }): Record => ({ decision: args.decision ?? (args.outcome === "answered" ? "accept" : args.outcome), outcome: args.outcome, - resolved: args.outcome !== "pending" && !args.awaitingUserResponse, + resolved: !args.awaitingUserResponse, answered: args.outcome === "answered", declined: args.outcome === "declined", cancelled: args.outcome === "cancelled", timedOut: args.outcome === "timed_out", awaitingUserResponse: args.awaitingUserResponse, blocking: args.blocking, - ...(args.intervention ? { intervention: args.intervention } : {}), answers: args.answers ?? {}, responseText: args.responseText ?? null, }); - // ── Standalone chat session path (no missionId) ── - // Route through agentChatService.requestChatInput which creates an inline - // pending-input in the chat UI and blocks until the user answers. - if (!missionId) { - // Use server-authorized chatSessionId; reject unverified client-supplied ids. - const serverChatSessionId = callerCtx.chatSessionId; - const clientChatSessionId = session.identity.chatSessionId; - if (clientChatSessionId && serverChatSessionId && clientChatSessionId !== serverChatSessionId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "ask_user: client-supplied chatSessionId does not match server-authorized session.", - ); - } - const chatSessionId = serverChatSessionId ?? clientChatSessionId; - if (!chatSessionId || !runtime.agentChatService) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "ask_user requires either a missionId or an active chat session (chatSessionId).", - ); - } - - // Race the chat input against an optional timeout. - const inputPromise = runtime.agentChatService.requestChatInput({ - chatSessionId, - title, - body, - ...(structuredQuestions?.length ? { questions: structuredQuestions } : {}), - }); - const result = waitForResolutionMs > 0 - ? await Promise.race([ - inputPromise, - new Promise((resolve) => setTimeout(() => resolve(null), waitForResolutionMs)), - ]) - : await inputPromise; - - if (!result) { - return buildAskUserResult({ - awaitingUserResponse: true, - blocking: true, - outcome: "timed_out", - decision: "timeout", - responseText: summarizeAskUserDecision("timeout", null, false), - }); - } - const answered = result.decision !== "decline" && result.decision !== "cancel"; - const outcome = result.decision === "decline" - ? "declined" - : result.decision === "cancel" - ? "cancelled" - : "answered"; - return buildAskUserResult({ - awaitingUserResponse: false, - blocking: false, - outcome, - decision: result.decision, - answers: result.answers, - responseText: summarizeAskUserDecision(result.decision, result.responseText, answered), - }); - } - - if (askUserPolicy && !askUserPolicy.enabled) { + const serverChatSessionId = callerCtx.chatSessionId; + const clientChatSessionId = session.identity.chatSessionId; + if (clientChatSessionId && serverChatSessionId && clientChatSessionId !== serverChatSessionId) { throw new JsonRpcError( - JsonRpcErrorCode.policyDenied, - "Ask Questions is disabled for this phase. Proceed with the best grounded assumption instead.", + JsonRpcErrorCode.invalidParams, + "ask_user: client-supplied chatSessionId does not match server-authorized session.", ); } - if ( - askUserPolicy - && askUserPolicy.maxQuestions != null - && askUserPolicy.existingQuestions >= askUserPolicy.maxQuestions - ) { + const chatSessionId = serverChatSessionId ?? clientChatSessionId; + if (!chatSessionId || !runtime.agentChatService) { throw new JsonRpcError( - JsonRpcErrorCode.policyDenied, - `This phase already reached its Ask Questions limit (${askUserPolicy.maxQuestions}) for the active worker.`, + JsonRpcErrorCode.invalidParams, + "ask_user requires an active chat session (chatSessionId).", ); } - const resolvedPhase = phase ?? askUserPolicy?.phase ?? null; - const resolvedPhaseName = askUserPolicy?.phaseName ?? null; - const ownerKind = callerCtx.role === "orchestrator" - ? "coordinator" - : askUserPolicy?.ownerKind ?? "worker"; - const ownerLabel = callerCtx.role === "orchestrator" - ? "Coordinator question" - : askUserPolicy?.ownerLabel ?? "Worker question"; - - const intervention = runtime.missionService.addIntervention({ - missionId, - interventionType: "manual_input", + const inputPromise = runtime.agentChatService.requestChatInput({ + chatSessionId, title, body, - ...(requestedAction ? { requestedAction } : {}), - ...(laneId ? { laneId } : {}), - metadata: { - source: "ask_user", - ...(callerCtx.runId ? { runId: callerCtx.runId } : {}), - ...(resolvedPhase ? { phase: resolvedPhase } : {}), - ...(resolvedPhaseName ? { phaseName: resolvedPhaseName } : {}), - ...(askUserPolicy?.stepId ? { stepId: askUserPolicy.stepId } : {}), - ...(askUserPolicy?.stepKey ? { stepKey: askUserPolicy.stepKey } : {}), - questionOwnerKind: ownerKind, - questionOwnerLabel: ownerLabel, - ownerRole: callerCtx.role ?? "external", - blocking: true, - canProceedWithoutAnswer: false, - } + ...(structuredQuestions?.length ? { questions: structuredQuestions } : {}), }); - - if (callerCtx.runId) { - try { - runtime.orchestratorService.pauseRun({ - runId: callerCtx.runId, - reason: `Blocking user question: ${title.slice(0, 120)}`, - metadata: { - interventionSource: "ask_user", - interventionId: intervention.id, - }, - }); - } catch { - // Best-effort: the run may already be paused or terminal. - } - } - - if (session.identity.role === "orchestrator" || callerCtx.runId || waitForResolutionMs <= 0) { + const result = waitForResolutionMs > 0 + ? await Promise.race([ + inputPromise, + new Promise((resolve) => setTimeout(() => resolve(null), waitForResolutionMs)), + ]) + : await inputPromise; + + if (!result) { return buildAskUserResult({ - intervention, awaitingUserResponse: true, blocking: true, - outcome: "pending", + outcome: "timed_out", + decision: "timeout", + responseText: summarizeAskUserDecision("timeout", null, false), }); } - - const deadline = Date.now() + waitForResolutionMs; - while (Date.now() <= deadline) { - const mission = runtime.missionService.get(missionId); - const latest = mission?.interventions.find((entry) => entry.id === intervention.id) ?? null; - if (latest && latest.status !== "open") { - return buildAskUserResult({ - intervention: latest, - awaitingUserResponse: false, - blocking: false, - outcome: latest.status === "dismissed" ? "declined" : "answered", - }); - } - await sleep(pollIntervalMs); - } - - const mission = runtime.missionService.get(missionId); - const latest = mission?.interventions.find((entry) => entry.id === intervention.id) ?? intervention; + const answered = result.decision !== "decline" && result.decision !== "cancel"; + const outcome = result.decision === "decline" + ? "declined" + : result.decision === "cancel" + ? "cancelled" + : "answered"; return buildAskUserResult({ - intervention: latest, - awaitingUserResponse: true, - blocking: true, - outcome: "timed_out", + awaitingUserResponse: false, + blocking: false, + outcome, + decision: result.decision, + answers: result.answers, + responseText: summarizeAskUserDecision(result.decision, result.responseText, answered), }); } @@ -6007,52 +4385,6 @@ async function runTool(args: { return runtime.computerUseArtifactBrokerService.getBackendStatus(); } - if (name === "reflection_add") { - const missionId = asOptionalTrimmedString(toolArgs.missionId) ?? callerCtx.missionId; - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId; - const signalType = assertNonEmptyString(toolArgs.signalType, "signalType"); - const observation = assertNonEmptyString(toolArgs.observation, "observation"); - const agentRole = assertNonEmptyString(toolArgs.agentRole, "agentRole"); - const phase = assertNonEmptyString(toolArgs.phase, "phase"); - if (!missionId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "missionId is required (either argument or initialize identity)."); - } - if (!runId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "runId is required (either argument or initialize identity)."); - } - - let reflection; - try { - reflection = runtime.orchestratorService.addReflection({ - missionId, - runId, - stepId: asOptionalTrimmedString(toolArgs.stepId) ?? callerCtx.stepId, - attemptId: asOptionalTrimmedString(toolArgs.attemptId) ?? callerCtx.attemptId, - signalType: signalType as "wish" | "frustration" | "idea" | "pattern" | "limitation", - observation, - recommendation: assertNonEmptyString(toolArgs.recommendation, "recommendation"), - context: assertNonEmptyString(toolArgs.context, "context"), - agentRole, - phase, - occurredAt: assertNonEmptyString(toolArgs.occurredAt, "occurredAt"), - }); - } catch (error) { - if (error instanceof ReflectionValidationError) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Invalid reflection input [${error.code}]: ${error.message}`, - { - code: error.code, - ...(error.details ? { details: error.details } : {}) - } - ); - } - throw error; - } - - return { reflection }; - } - if (name === "run_tests") { const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); @@ -6689,7 +5021,7 @@ async function runTool(args: { promptSegments.push(buildAdeInlineGuidanceForLane(laneWorktreePath)); if (promptRunId || promptStepId || promptAttemptId) { promptSegments.push( - `Mission context: run=${promptRunId ?? "n/a"} step=${promptStepId ?? "n/a"} attempt=${promptAttemptId ?? "n/a"}.` + `Run context: run=${promptRunId ?? "n/a"} step=${promptStepId ?? "n/a"} attempt=${promptAttemptId ?? "n/a"}.` ); } if (contextRef.contextFilePath) { @@ -6769,7 +5101,6 @@ async function runTool(args: { addWorkerEnv("ADE_RUN_ID", runId); addWorkerEnv("ADE_STEP_ID", stepId); addWorkerEnv("ADE_ATTEMPT_ID", attemptId); - addWorkerEnv("ADE_MISSION_ID", callerCtx.missionId); addWorkerEnv("ADE_OWNER_ID", callerCtx.ownerId); workerEnv.ADE_DEFAULT_ROLE = "agent"; envPrefixParts.push("ADE_DEFAULT_ROLE=agent"); @@ -6809,191 +5140,6 @@ async function runTool(args: { }; } - // ── Mission Lifecycle Tools ────────────────────────────────────── - - if (name === "create_mission") { - - const prompt = assertNonEmptyString(toolArgs.prompt, "prompt"); - const title = asOptionalTrimmedString(toolArgs.title); - const laneId = asOptionalTrimmedString(toolArgs.laneId); - const priority = asOptionalTrimmedString(toolArgs.priority); - const executionMode = asOptionalTrimmedString(toolArgs.executionMode); - const prStrategy = asOptionalTrimmedString(toolArgs.prStrategy); - const executorPolicy = isRecord(toolArgs.executorPolicy) ? toolArgs.executorPolicy : undefined; - - const createArgs: Record = { prompt }; - if (title) createArgs.title = title; - if (laneId) createArgs.laneId = laneId; - if (priority) createArgs.priority = priority; - if (executionMode) createArgs.executionMode = executionMode; - if (prStrategy) createArgs.prStrategy = prStrategy; - if (executorPolicy) createArgs.executionPolicy = executorPolicy; - const mission = runtime.missionService.create(createArgs as any); - - runtime.eventBuffer.push({ - timestamp: nowIso(), - category: "mission", - payload: { type: "mission_created", missionId: mission.id } - }); - - return { mission }; - } - - if (name === "start_mission") { - - const missionId = assertNonEmptyString(toolArgs.missionId, "missionId"); - const runMode = asOptionalTrimmedString(toolArgs.runMode) as "autopilot" | "manual" | null; - const defaultExecutorKind = asOptionalTrimmedString(toolArgs.defaultExecutorKind); - const coordinatorModel = asOptionalTrimmedString(toolArgs.coordinatorModel); - - const startArgs: Record = { missionId }; - if (runMode) startArgs.runMode = runMode; - if (defaultExecutorKind) startArgs.defaultExecutorKind = defaultExecutorKind; - if (coordinatorModel) startArgs.defaultModelId = coordinatorModel; - const result = await runtime.aiOrchestratorService.startMissionRun(startArgs as any); - - const runId = result.started?.run.id ?? null; - - runtime.eventBuffer.push({ - timestamp: nowIso(), - category: "mission", - payload: { type: "mission_started", missionId, runId } - }); - - return { ...result, runId }; - } - - if (name === "pause_mission") { - - const runId = assertNonEmptyString(toolArgs.runId, "runId"); - const reason = asOptionalTrimmedString(toolArgs.reason); - const run = runtime.orchestratorService.pauseRun({ - runId, - ...(reason ? { reason } : {}) - }); - return { run }; - } - - if (name === "resume_mission") { - - const runId = assertNonEmptyString(toolArgs.runId, "runId"); - const run = await runtime.aiOrchestratorService.resumeRun({ runId }); - return { run }; - } - - if (name === "cancel_mission") { - - const runId = assertNonEmptyString(toolArgs.runId, "runId"); - const reason = asOptionalTrimmedString(toolArgs.reason); - const result = await runtime.aiOrchestratorService.cancelRunGracefully({ - runId, - ...(reason ? { reason } : {}) - }); - return result; - } - - if (name === "steer_mission") { - - const missionId = assertNonEmptyString(toolArgs.missionId, "missionId"); - const directive = assertNonEmptyString(toolArgs.directive, "directive"); - const targetStepKey = asOptionalTrimmedString(toolArgs.targetStepKey); - const interventionId = asOptionalTrimmedString(toolArgs.interventionId); - const resolutionKind = asOptionalTrimmedString(toolArgs.resolutionKind); - const priorityRaw = asOptionalTrimmedString(toolArgs.priority); - const validPriorities = new Set(["suggestion", "instruction", "override"]); - const priority: "suggestion" | "instruction" | "override" = - priorityRaw && validPriorities.has(priorityRaw) - ? (priorityRaw as "suggestion" | "instruction" | "override") - : "suggestion"; - const result = runtime.aiOrchestratorService.steerMission({ - missionId, - directive, - priority, - ...(targetStepKey ? { targetStepKey } : {}), - ...(interventionId ? { interventionId } : {}), - ...(resolutionKind ? { resolutionKind: resolutionKind as any } : {}) - }); - return result; - } - - if (name === "resolve_intervention") { - - const missionId = assertNonEmptyString(toolArgs.missionId, "missionId"); - const interventionId = assertNonEmptyString(toolArgs.interventionId, "interventionId"); - const statusRaw = assertNonEmptyString(toolArgs.status, "status"); - const status = statusRaw === "dismissed" ? "dismissed" as const : "resolved" as const; - const note = asOptionalTrimmedString(toolArgs.note); - const resolutionKind = asOptionalTrimmedString(toolArgs.resolutionKind); - const intervention = runtime.missionService.resolveIntervention({ - missionId, - interventionId, - status, - ...(note ? { note } : {}), - ...(resolutionKind ? { resolutionKind: resolutionKind as any } : {}) - }); - return { intervention }; - } - - // ── Observation Tools ──────────────────────────────────────────── - // Observation tools support auto-population from caller context (env vars). - // When a worker calls these tools without explicit IDs, they default to - // the worker's own mission/run context. - - if (name === "get_mission") { - const missionId = asOptionalTrimmedString(toolArgs.missionId) ?? callerCtx.missionId; - if (!missionId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "missionId is required (not available from caller context)"); - } - const mission = runtime.missionService.get(missionId); - if (!mission) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Mission not found: ${missionId}`); - } - return { mission }; - } - - if (name === "list_retrospectives") { - const missionId = asOptionalTrimmedString(toolArgs.missionId) ?? callerCtx.missionId ?? undefined; - const limit = asNumber(toolArgs.limit, 20); - const retrospectives = runtime.orchestratorService.listRetrospectives({ - ...(missionId ? { missionId } : {}), - limit - }); - return { retrospectives }; - } - - if (name === "list_reflection_trends") { - const missionId = asOptionalTrimmedString(toolArgs.missionId) ?? callerCtx.missionId ?? undefined; - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId ?? undefined; - const limit = asNumber(toolArgs.limit, 100); - const trends = runtime.orchestratorService.listRetrospectiveTrends({ - ...(missionId ? { missionId } : {}), - ...(runId ? { runId } : {}), - limit - }); - return { trends }; - } - - if (name === "list_reflection_pattern_stats") { - const limit = asNumber(toolArgs.limit, 100); - const patternStats = runtime.orchestratorService.listRetrospectivePatternStats({ limit }); - return { patternStats }; - } - - if (name === "get_run_graph") { - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId; - if (!runId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "runId is required (not available from caller context)"); - } - const timelineLimit = asNumber(toolArgs.timelineLimit, 300); - try { - const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit }); - return { graph }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Run not found or inaccessible: ${runId}. ${msg}`); - } - } - if (name === "stream_events") { const cursor = asNumber(toolArgs.cursor, 0); const limit = asNumber(toolArgs.limit, 100); @@ -7016,304 +5162,6 @@ async function runTool(args: { return runtime.eventBuffer.drain(cursor, limit); } - if (name === "get_step_output") { - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId; - if (!runId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "runId is required (not available from caller context)"); - } - const stepKey = assertNonEmptyString(toolArgs.stepKey, "stepKey"); - const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const step = graph.steps.find((s) => s.stepKey === stepKey); - if (!step) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Step not found: ${stepKey}`); - } - const attempts = graph.attempts.filter((a) => a.stepId === step.id); - return { step, attempts }; - } - - if (name === "get_worker_states") { - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId; - if (!runId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "runId is required (not available from caller context)"); - } - const states = runtime.aiOrchestratorService.getWorkerStates({ runId }); - const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const runWorkers = graph.steps - .filter(isRunWorkerStep) - .map((step) => { - const runningAttempt = graph.attempts.find((attempt) => attempt.stepId === step.id && attempt.status === "running"); - const latestAttempt = [...graph.attempts] - .filter((attempt) => attempt.stepId === step.id) - .sort((a, b) => String(b.completedAt ?? b.createdAt ?? "").localeCompare(String(a.completedAt ?? a.createdAt ?? "")))[0]; - return { - workerId: step.stepKey, - stepId: step.id, - title: step.title, - status: step.status, - phaseKey: isRecord(step.metadata) && typeof step.metadata.phaseKey === "string" ? step.metadata.phaseKey : null, - hasRunningAttempt: Boolean(runningAttempt), - activeAttemptId: runningAttempt?.id ?? null, - lastAttemptStatus: latestAttempt?.status ?? null, - retryCount: step.retryCount, - }; - }); - return { runId, workers: states, runWorkers }; - } - - if (name === "get_timeline") { - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId; - if (!runId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "runId is required (not available from caller context)"); - } - const limit = asNumber(toolArgs.limit, 300); - const stepId = asOptionalTrimmedString(toolArgs.stepId); - const timeline = runtime.orchestratorService.listTimeline({ runId, limit }); - if (stepId) { - const filtered = timeline.filter((e) => e.stepId === stepId); - return { timeline: filtered }; - } - return { timeline }; - } - - if (name === "get_mission_metrics") { - const missionId = asOptionalTrimmedString(toolArgs.missionId) ?? callerCtx.missionId; - if (!missionId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "missionId is required (not available from caller context)"); - } - const metrics = runtime.aiOrchestratorService.getMissionMetrics({ missionId }); - return { metrics }; - } - - if (name === "get_final_diff") { - const runId = asOptionalTrimmedString(toolArgs.runId) ?? callerCtx.runId; - if (!runId) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "runId is required (not available from caller context)"); - } - const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const laneIds = [...new Set(graph.steps.map((s) => s.laneId).filter(Boolean))] as string[]; - const diffs: Record = {}; - for (const laneId of laneIds) { - diffs[laneId] = await runtime.diffService.getChanges(laneId); - } - return { runId, diffs }; - } - - // ── Worker Collaboration Tools ───────────────────────────────── - - if (name === "get_pending_messages") { - const missionId = callerCtx.missionId; - const attemptId = callerCtx.attemptId ?? session.identity.attemptId; - if (!missionId || !attemptId) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "get_pending_messages requires worker identity (ADE_MISSION_ID and ADE_ATTEMPT_ID env vars). This tool is only available to workers spawned by the orchestrator." - ); - } - - const sinceCursor = asOptionalTrimmedString(toolArgs.since_cursor); - const limit = Math.max(1, Math.min(200, Math.floor(asNumber(toolArgs.limit, 50)))); - - // Find the worker's thread by attemptId - const threads = runtime.aiOrchestratorService.listChatThreads({ missionId }); - const workerThread = threads.find( - (t: Record) => - (t.attemptId === attemptId) || - (t.threadType === "worker" && t.attemptId === attemptId) - ); - - if (!workerThread) { - return { - messages: [], - workerAttemptId: attemptId, - missionId, - threadId: null, - note: "No message thread found for this worker yet" - }; - } - - const threadId = String(workerThread.id ?? ""); - const allMessages = runtime.aiOrchestratorService.getThreadMessages({ - missionId, - threadId, - limit: limit * 2 // fetch extra to filter - }); - - // Filter to messages addressed to this worker (not from this worker) - let messages = allMessages.filter((msg: Record) => { - // Include messages from coordinator, user, or other agents - if (msg.role === "orchestrator" || msg.role === "user" || msg.role === "system") return true; - // Include inter-agent messages targeted at this worker - const target = msg.target as Record | null | undefined; - if (target?.targetAttemptId === attemptId && msg.attemptId !== attemptId) return true; - // Include agent messages that are inter-agent deliveries to this thread - const metadata = msg.metadata as Record | null | undefined; - if (metadata?.interAgentDelivery === true && msg.attemptId !== attemptId) return true; - return false; - }); - - // Apply cursor filter - if (sinceCursor) { - const cursorTime = Date.parse(sinceCursor); - if (!Number.isNaN(cursorTime)) { - messages = messages.filter((msg: Record) => { - const ts = Date.parse(String(msg.timestamp ?? "")); - return !Number.isNaN(ts) && ts > cursorTime; - }); - } - } - - // Limit - messages = messages.slice(-limit); - - return { - messages: messages.map((msg: Record) => ({ - id: msg.id, - role: msg.role, - content: msg.content, - timestamp: msg.timestamp, - stepKey: msg.stepKey ?? null, - attemptId: msg.attemptId ?? null, - metadata: msg.metadata ?? null - })), - workerAttemptId: attemptId, - missionId, - threadId, - count: messages.length - }; - } - - // ── Evaluation Tools ───────────────────────────────────────────── - - if (name === "evaluate_run") { - - const runId = assertNonEmptyString(toolArgs.runId, "runId"); - const missionId = assertNonEmptyString(toolArgs.missionId, "missionId"); - const scores = safeObject(toolArgs.scores); - const issues = Array.isArray(toolArgs.issues) ? toolArgs.issues : []; - const summary = assertNonEmptyString(toolArgs.summary, "summary"); - const improvements = Array.isArray(toolArgs.improvements) ? toolArgs.improvements : []; - const metadata = isRecord(toolArgs.metadata) ? toolArgs.metadata : {}; - - const id = randomUUID(); - const evaluatedAt = nowIso(); - - runtime.db.run( - `INSERT INTO orchestrator_evaluations (id, project_id, run_id, mission_id, evaluator_id, scores_json, issues_json, summary, improvements_json, metadata_json, evaluated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - id, - runtime.projectId, - runId, - missionId, - session.identity.callerId, - JSON.stringify(scores), - JSON.stringify(issues), - summary, - JSON.stringify(improvements), - JSON.stringify(metadata), - evaluatedAt - ] - ); - - return { - id, - runId, - missionId, - evaluatorId: session.identity.callerId, - scores, - issues, - summary, - improvements, - evaluatedAt - }; - } - - if (name === "list_evaluations") { - const missionId = asOptionalTrimmedString(toolArgs.missionId); - const runId = asOptionalTrimmedString(toolArgs.runId); - const limit = Math.max(1, Math.min(100, Math.floor(asNumber(toolArgs.limit, 20)))); - - const where: string[] = ["project_id = ?"]; - const params: Array = [runtime.projectId]; - if (missionId) { where.push("mission_id = ?"); params.push(missionId); } - if (runId) { where.push("run_id = ?"); params.push(runId); } - params.push(limit); - - const rows = runtime.db.all<{ - id: string; run_id: string; mission_id: string; evaluator_id: string; - scores_json: string; issues_json: string; summary: string; - improvements_json: string | null; metadata_json: string | null; evaluated_at: string; - }>( - `SELECT id, run_id, mission_id, evaluator_id, scores_json, issues_json, summary, improvements_json, metadata_json, evaluated_at - FROM orchestrator_evaluations - WHERE ${where.join(" AND ")} - ORDER BY evaluated_at DESC - LIMIT ?`, - params - ); - - const evaluations = rows.map((row) => ({ - id: row.id, - runId: row.run_id, - missionId: row.mission_id, - evaluatorId: row.evaluator_id, - scores: JSON.parse(row.scores_json), - issueCount: JSON.parse(row.issues_json).length, - summary: row.summary, - evaluatedAt: row.evaluated_at - })); - - return { evaluations }; - } - - if (name === "get_evaluation_report") { - const evaluationId = assertNonEmptyString(toolArgs.evaluationId, "evaluationId"); - - const row = runtime.db.get<{ - id: string; run_id: string; mission_id: string; evaluator_id: string; - scores_json: string; issues_json: string; summary: string; - improvements_json: string | null; metadata_json: string | null; evaluated_at: string; - }>( - `SELECT id, run_id, mission_id, evaluator_id, scores_json, issues_json, summary, improvements_json, metadata_json, evaluated_at - FROM orchestrator_evaluations - WHERE id = ? AND project_id = ?`, - [evaluationId, runtime.projectId] - ); - - if (!row) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Evaluation not found: ${evaluationId}`); - } - - let runContext: Record | null = null; - try { - const graph = runtime.orchestratorService.getRunGraph({ runId: row.run_id, timelineLimit: 50 }); - runContext = { - run: graph.run, - stepCount: graph.steps.length, - attemptCount: graph.attempts.length, - completionEvaluation: graph.completionEvaluation - }; - } catch { - // Run may no longer exist - } - - return { - evaluation: { - id: row.id, - runId: row.run_id, - missionId: row.mission_id, - evaluatorId: row.evaluator_id, - scores: JSON.parse(row.scores_json), - issues: JSON.parse(row.issues_json), - summary: row.summary, - improvements: row.improvements_json ? JSON.parse(row.improvements_json) : [], - metadata: row.metadata_json ? JSON.parse(row.metadata_json) : {}, - evaluatedAt: row.evaluated_at - }, - runContext - }; - } - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unknown ADE action: ${name}`); } @@ -7378,7 +5226,6 @@ export function createAdeRpcRequestHandler(args: { role: "external", chatSessionId: null, standaloneChatSession: false, - missionId: null, runId: null, stepId: null, attemptId: null, @@ -7406,7 +5253,6 @@ export function createAdeRpcRequestHandler(args: { callerId: session.identity.callerId, role: session.identity.role, chatSessionId: session.identity.chatSessionId, - missionId: session.identity.missionId, runId: session.identity.runId, stepId: session.identity.stepId, attemptId: session.identity.attemptId, @@ -7457,11 +5303,6 @@ export function createAdeRpcRequestHandler(args: { if ( READ_ONLY_TOOLS.has(actionName) || MUTATION_TOOLS.has(actionName) || - ORCHESTRATION_TOOLS.has(actionName) || - OBSERVATION_TOOLS.has(actionName) || - EVALUATOR_TOOLS.has(actionName) || - EVALUATION_READ_TOOLS.has(actionName) || - COORDINATOR_TOOL_NAMES.has(actionName) || actionName === "spawn_agent" || actionName === "ask_user" ) { diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index e94720016..d21209c48 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -56,7 +56,7 @@ describe("createEventBuffer", () => { it("returns empty result when cursor is at the end", () => { const buffer = createEventBuffer(); - buffer.push({ timestamp: "2026-03-01T00:00:00Z", category: "mission", payload: {} }); + buffer.push({ timestamp: "2026-03-01T00:00:00Z", category: "runtime", payload: {} }); const result = buffer.drain(1); expect(result.events).toHaveLength(0); @@ -134,14 +134,14 @@ describe("createEventBuffer", () => { it("preserves event category and payload through push and drain", () => { const buffer = createEventBuffer(); - const categories: BufferedEvent["category"][] = ["orchestrator", "dag_mutation", "runtime", "mission", "pty"]; + const categories: BufferedEvent["category"][] = ["orchestrator", "dag_mutation", "runtime", "pty"]; for (const category of categories) { buffer.push({ timestamp: "t", category, payload: { kind: category } }); } const result = buffer.drain(0); - expect(result.events).toHaveLength(5); + expect(result.events).toHaveLength(categories.length); for (let i = 0; i < categories.length; i++) { expect(result.events[i]!.category).toBe(categories[i]); expect(result.events[i]!.payload).toEqual({ kind: categories[i] }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 941825c63..0f870d65c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -18,8 +18,6 @@ import { createProjectConfigService } from "../../desktop/src/main/services/conf import { createConflictService } from "../../desktop/src/main/services/conflicts/conflictService"; 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 { 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"; @@ -57,11 +55,8 @@ import type { createLinearIssueTracker } from "../../desktop/src/main/services/c import type { createLinearIngressService } from "../../desktop/src/main/services/cto/linearIngressService"; import type { createLinearRoutingService } from "../../desktop/src/main/services/cto/linearRoutingService"; import type { createLinearSyncService } from "../../desktop/src/main/services/cto/linearSyncService"; -import { createOrchestratorService } from "../../desktop/src/main/services/orchestrator/orchestratorService"; -import { createAiOrchestratorService } from "../../desktop/src/main/services/orchestrator/aiOrchestratorService"; import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService"; import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore"; -import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; import type { createSyncService } from "./services/sync/syncService"; import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService"; import { getSharedModelPickerStore } from "./services/modelPickerStore"; @@ -138,7 +133,6 @@ export type AdeRuntimePaths = { chatSessionsDir: string; chatTranscriptsDir: string; orchestratorCacheDir: string; - missionStateDir: string; }; export type AdeRuntimeSyncOptions = { @@ -186,8 +180,6 @@ export type AdeRuntime = { conflictService: ReturnType; gitService: ReturnType; diffService: ReturnType; - missionService: ReturnType; - missionPreflightService?: ReturnType | null; ptyService: ReturnType; testService: ReturnType; aiIntegrationService?: ReturnType | null; @@ -221,9 +213,6 @@ export type AdeRuntime = { appControlService?: AppControlService | null; builtInBrowserService?: BuiltInBrowserService | null; macosVmService?: ReturnType | null; - orchestratorService: ReturnType; - aiOrchestratorService: ReturnType; - missionBudgetService?: ReturnType | null; syncHostService?: ReturnType | null; syncService?: ReturnType | null; apnsService?: ApnsService | null; @@ -259,7 +248,6 @@ export function ensureAdePaths(projectRoot: string): AdeRuntimePaths { chatSessionsDir: paths.chatSessionsDir, chatTranscriptsDir: paths.chatTranscriptsDir, orchestratorCacheDir: paths.orchestratorCacheDir, - missionStateDir: paths.missionStateDir, }; } @@ -621,12 +609,6 @@ export async function createAdeRuntime(args: { const diffService = createDiffService({ laneService }); - const missionService = createMissionService({ - db, - projectId, - onEvent: (event) => pushEvent("mission", event as unknown as Record) - }); - const ptyService = createPtyService({ projectRoot, transcriptsDir: paths.transcriptsDir, @@ -682,31 +664,6 @@ export async function createAdeRuntime(args: { broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record), }); - // Ensure evaluation tables exist for headless runtime checks. - db.run(` - CREATE TABLE IF NOT EXISTS orchestrator_evaluations ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - run_id TEXT NOT NULL, - mission_id TEXT NOT NULL, - evaluator_id TEXT NOT NULL, - scores_json TEXT NOT NULL, - issues_json TEXT NOT NULL, - summary TEXT NOT NULL, - improvements_json TEXT, - metadata_json TEXT, - evaluated_at TEXT NOT NULL - ) - `); - db.run(` - CREATE INDEX IF NOT EXISTS idx_orchestrator_evaluations_mission - ON orchestrator_evaluations(mission_id, evaluated_at) - `); - db.run(` - CREATE INDEX IF NOT EXISTS idx_orchestrator_evaluations_run - ON orchestrator_evaluations(run_id, evaluated_at) - `); - const ctoStateService = createCtoStateService({ db, projectId, @@ -737,79 +694,13 @@ export async function createAdeRuntime(args: { projectId, workerAgentService, }); - const missionBudgetService = createMissionBudgetService({ - db, - logger, - projectId, - projectRoot, - missionService, - aiIntegrationService, - projectConfigService, - }); - - let aiOrchestratorServiceRef: ReturnType | null = null; - const aiCoordinatorWakeReasons = new Set([ - "attempt_completed", - "completed", - "failed", - "skipped", - "finalized", - "intervention_resolved", - "question_answered_resume", - "resume_recovered", - "validation_contract_unfulfilled", - "validation_self_check_reminder", - "validation_auto_spawned", - "validation_gate_blocked", - ]); - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - conflictService, - ptyService, - prService: undefined, - projectConfigService, - onEvent: (e) => { - pushEvent("orchestrator", e as unknown as Record); - if (aiCoordinatorWakeReasons.has(e.reason)) { - aiOrchestratorServiceRef?.onOrchestratorRuntimeEvent(e); - } - if ( - e.reason === "validation_contract_unfulfilled" || - e.reason === "validation_self_check_reminder" || - e.reason === "validation_auto_spawned" || - e.reason === "validation_gate_blocked" - ) { - pushEvent("runtime", { - type: e.reason, - runId: e.runId ?? null, - stepId: e.stepId ?? null, - attemptId: e.attemptId ?? null, - }); - } - } - }); - const computerUseArtifactBrokerService = createComputerUseArtifactBrokerService({ db, projectId, projectRoot, - missionService, - orchestratorService, logger, onEvent: (event) => pushEvent("runtime", { type: "computer_use_event", event }), - }); - const missionPreflightService = createMissionPreflightService({ - logger, - projectRoot, - missionService, - laneService, - aiIntegrationService, - projectConfigService, - missionBudgetService, - computerUseArtifactBrokerService, - }); + } as Parameters[0]); const iosSimulatorService = chatOnlyRuntime ? null : createIosSimulatorService({ @@ -881,22 +772,6 @@ export async function createAdeRuntime(args: { logger, }); - const aiOrchestratorService = createAiOrchestratorService({ - db, - logger, - missionService, - orchestratorService, - agentChatService: null, - laneService, - projectConfigService, - aiIntegrationService, - prService: undefined, - projectRoot, - onThreadEvent: (e) => pushEvent("runtime", e as unknown as Record), - onDagMutation: (e) => pushEvent("dag_mutation", e as unknown as Record) - }); - aiOrchestratorServiceRef = aiOrchestratorService; - const headlessLinearServices = createHeadlessLinearServices({ projectRoot, adeDir: paths.adeDir, @@ -908,12 +783,9 @@ export async function createAdeRuntime(args: { laneService, operationService, conflictService, - missionService, - aiOrchestratorService, workerAgentService, workerBudgetService, computerUseArtifactBrokerService, - orchestratorService, openExternal: async () => {}, onGitHubStatusChanged: (status) => pushEvent("runtime", { type: "github_status_changed", event: status }), @@ -946,8 +818,6 @@ export async function createAdeRuntime(args: { workerHeartbeatService: headlessLinearServices.workerHeartbeatService, linearIssueTracker: headlessLinearServices.linearIssueTracker, flowPolicyService: headlessLinearServices.flowPolicyService, - getMissionService: () => missionService, - getAiOrchestratorService: () => aiOrchestratorService, getLinearDispatcherService: () => headlessLinearServices.linearDispatcherService, linearClient: headlessLinearServices.linearClient, linearCredentials: headlessLinearServices.linearCredentialService as never, @@ -960,7 +830,6 @@ export async function createAdeRuntime(args: { getGitService: () => gitService, conflictService, getWorkerBudgetService: () => workerBudgetService, - getMissionBudgetService: () => missionBudgetService, computerUseArtifactBrokerService, laneService, sessionService, @@ -972,7 +841,6 @@ export async function createAdeRuntime(args: { appVersion: "ade-cli", getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, onEvent: (event) => { - aiOrchestratorService.onAgentChatEvent(event); pushEvent("runtime", event as unknown as Record); }, onSessionEnded: (event) => { @@ -985,23 +853,9 @@ export async function createAdeRuntime(args: { } } agentChatServiceHolder.current = agentChatService; - if (typeof (aiOrchestratorService as { setAgentChatService?: (svc: typeof agentChatService) => void }).setAgentChatService === "function") { - (aiOrchestratorService as { setAgentChatService: (svc: typeof agentChatService) => void }).setAgentChatService(agentChatService); - } if (resolvedArgs.chatRuntime === "agent" && !agentChatService) { throw new Error("Agent chat runtime was requested but the agent chat service was not initialized."); } - if (resolvedArgs.chatRuntime === "agent" && agentChatService) { - setImmediate(() => { - try { - aiOrchestratorService.resumeActiveTeamRuntimes(); - } catch (error) { - logger.warn("bootstrap.resume_active_team_runtimes_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }); - } const reviewService = agentChatService ? createReviewService({ db, @@ -1043,8 +897,6 @@ export async function createAdeRuntime(args: { conflictService, testService, agentChatService: agentChatService ?? undefined, - missionService, - aiOrchestratorService, onEvent: (event) => pushEvent("runtime", { ...event, source: "automations" }), }); automationServiceRef = automationService; @@ -1117,13 +969,6 @@ export async function createAdeRuntime(args: { error: error instanceof Error ? error.message : String(error), }); } - automationService.bindMissionRuntime({ - missionService, - aiOrchestratorService, - budgetCapService, - workerHeartbeatService: headlessLinearServices.workerHeartbeatService, - }); - let syncService: ReturnType | null = null; if (resolvedArgs.syncRuntime?.enabled && agentChatService) { const { createSyncService } = await import("./services/sync/syncService"); @@ -1153,7 +998,6 @@ export async function createAdeRuntime(args: { rebaseSuggestionService, autoRebaseService, computerUseArtifactBrokerService, - missionService, agentChatService, workerAgentService, workerBudgetService, @@ -1218,9 +1062,6 @@ export async function createAdeRuntime(args: { conflictService, gitService, diffService, - missionService, - missionPreflightService, - missionBudgetService, syncService, syncHostService: syncService?.getHostService() ?? null, apnsService, @@ -1262,8 +1103,6 @@ export async function createAdeRuntime(args: { appControlService, builtInBrowserService: builtInBrowserBridge as unknown as BuiltInBrowserService | null, macosVmService, - orchestratorService, - aiOrchestratorService, eventBuffer, dispose: () => { const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; @@ -1284,7 +1123,6 @@ export async function createAdeRuntime(args: { swallow(() => macosVmService?.dispose()); swallow(() => linearOAuthService.dispose()); swallow(() => headlessLinearServices.dispose()); - swallow(() => aiOrchestratorService.dispose()); swallow(() => agentChatService?.forceDisposeAll?.()); swallow(() => testService.disposeAll()); swallow(() => ptyService.disposeAll()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f11a8d5ac..d690bb085 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -519,274 +519,31 @@ describe("ADE CLI", () => { const scalarCall = buildCliPlan([ "actions", "run", - "mission.get", + "git.getConflictState", "--scalar", - "mission-1", + "lane-1", ]); expect(scalarCall.kind).toBe("execute"); if (scalarCall.kind !== "execute") return; expect(scalarCall.steps[0]?.params).toEqual({ name: "run_ade_action", arguments: { - domain: "mission", - action: "get", - arg: "mission-1", + domain: "git", + action: "getConflictState", + arg: "lane-1", }, }); }); - it("builds typed mission create with custom phase and planned-step payload files", () => { - const root = fs.mkdtempSync( - path.join(os.tmpdir(), "ade-cli-mission-plan-"), - ); - const phasesPath = path.join(root, "phases.json"); - const stepsPath = path.join(root, "steps.json"); - fs.writeFileSync( - phasesPath, - JSON.stringify([{ phaseKey: "planning", name: "Planning", position: 0 }]), - ); - fs.writeFileSync( - stepsPath, - JSON.stringify([ - { - index: 0, - title: "Plan", - detail: "Plan it", - kind: "planning", - metadata: {}, - }, - ]), - ); - const plan = buildCliPlan([ - "missions", - "create", - "--prompt", - "Try the mission backend", - "--manual", - "--phase-override-file", - phasesPath, - "--planned-steps-file", - stepsPath, - ]); - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.formatter).toBe("mission-detail"); - expect(plan.steps[0]?.params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "mission", - action: "create", - args: expect.objectContaining({ - prompt: "Try the mission backend", - launchMode: "manual", - phaseOverride: [ - { phaseKey: "planning", name: "Planning", position: 0 }, - ], - plannedSteps: [ - { - index: 0, - title: "Plan", - detail: "Plan it", - kind: "planning", - metadata: {}, - }, - ], - }), - }, - }); - }); - it("reports unreadable JSON payload files as CLI usage errors", () => { - const missingPath = path.join(os.tmpdir(), "ade-cli-missing-phases.json"); - expect(() => - buildCliPlan([ - "missions", - "create", - "--prompt", - "Try the mission backend", - "--phase-override-file", - missingPath, - ]), - ).toThrow(/Could not read --phase-override-file file/); - }); - it("builds typed mission launch with a dependent start step", () => { - const plan = buildCliPlan([ - "mission", - "launch", - "--prompt", - "Run throwaway mission", - "--manual", - "--executor", - "codex", - ]); - - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.formatter).toBe("mission-watch"); - expect(plan.steps).toHaveLength(2); - expect(plan.steps[0]?.params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "mission", - action: "create", - args: expect.objectContaining({ - prompt: "Run throwaway mission", - launchMode: "manual", - autopilotExecutor: "codex", - }), - }, - }); - expect(typeof plan.steps[1]?.params).toBe("function"); - const params = - typeof plan.steps[1]?.params === "function" - ? plan.steps[1].params({ - created: { - domain: "mission", - action: "create", - result: { id: "mission-1" }, - }, - }) - : null; - expect(params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "orchestrator", - action: "startMissionRun", - args: { - missionId: "mission-1", - runMode: "manual", - defaultExecutorKind: "codex", - }, - }, - }); - }); - - it("builds typed mission launch wait mode with post-wait mission and graph reads", () => { - const plan = buildCliPlan([ - "missions", - "launch", - "--prompt", - "Run and observe", - "--manual", - "--wait-ms", - "5000", - ]); - - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.steps).toHaveLength(4); - expect(typeof plan.steps[2]?.params).toBe("function"); - const missionParams = - typeof plan.steps[2]?.params === "function" - ? plan.steps[2].params({ - created: { - domain: "mission", - action: "create", - result: { id: "mission-1" }, - }, - }) - : null; - expect(missionParams).toEqual({ - name: "run_ade_action", - arguments: { - domain: "mission", - action: "get", - arg: "mission-1", - }, - }); - expect(plan.steps[3]).toMatchObject({ - key: "graph", - method: "ade-cli/wait-run-graph", - }); - expect(typeof plan.steps[3]?.params).toBe("function"); - const graphParams = - typeof plan.steps[3]?.params === "function" - ? plan.steps[3].params({ - started: { - domain: "orchestrator", - action: "startMissionRun", - result: { - started: { run: { id: "run-1" } }, - mission: { id: "mission-1" }, - }, - }, - }) - : null; - expect(graphParams).toEqual({ - runId: "run-1", - waitMs: 5000, - untilTerminal: false, - timelineLimit: 120, - }); - }); - - it("builds mission resume through the AI orchestrator so coordinator recovery runs", () => { - const plan = buildCliPlan(["missions", "resume", "run-1"]); - - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.formatter).toBe("mission-graph"); - expect(plan.steps[0]?.params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "orchestrator", - action: "resumeRun", - args: { - runId: "run-1", - }, - }, - }); - }); - - it("builds mission resume wait mode with a post-wait graph read", () => { - const plan = buildCliPlan([ - "missions", - "resume", - "run-1", - "--wait-ms", - "5000", - "--timeline-limit", - "200", - ]); - - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.formatter).toBe("mission-watch"); - expect(plan.steps).toHaveLength(2); - expect(plan.steps[0]?.params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "orchestrator", - action: "resumeRun", - args: { - runId: "run-1", - }, - }, - }); - expect(plan.steps[1]).toMatchObject({ - key: "graph", - method: "ade-cli/wait-run-graph", - }); - expect(typeof plan.steps[1]?.params).toBe("function"); - const graphParams = - typeof plan.steps[1]?.params === "function" - ? plan.steps[1].params({}) - : null; - expect(graphParams).toEqual({ - runId: "run-1", - waitMs: 5000, - untilTerminal: false, - timelineLimit: 200, - }); - }); it("reads wait state from run graphs with a nested visual graph", () => { const waitState = graphWaitState({ - domain: "orchestrator_core", + domain: "graph_state", action: "getRunGraph", result: { graph: { @@ -801,97 +558,7 @@ describe("ADE CLI", () => { expect(waitState).toEqual({ status: "succeeded", activeCount: 0 }); }); - it("builds mission cancel with a run id for graceful cancellation", () => { - const plan = buildCliPlan([ - "missions", - "cancel", - "run-1", - "--reason", - "superseded", - ]); - - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.formatter).toBe("mission-detail"); - expect(plan.steps[0]?.params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "orchestrator", - action: "cancelRunGracefully", - args: { - runId: "run-1", - reason: "superseded", - }, - }, - }); - }); - - it("builds shell-friendly worker mission tool aliases", () => { - const pending = buildCliPlan(["get_pending_messages", "--limit", "10"]); - expect(pending.kind).toBe("execute"); - if (pending.kind !== "execute") return; - expect(pending.steps[0]?.params).toEqual({ - name: "get_pending_messages", - arguments: { - since_cursor: null, - limit: 10, - }, - }); - - const message = buildCliPlan([ - "message_worker", - "worker_consumer", - "--content", - "Contract helper is ready", - "--priority", - "high", - ]); - expect(message.kind).toBe("execute"); - if (message.kind !== "execute") return; - expect(message.steps[0]?.params).toEqual({ - name: "message_worker", - arguments: { - fromWorkerId: null, - toWorkerId: "worker_consumer", - content: "Contract helper is ready", - 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", - }, - }); - 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", () => { expect(() => @@ -1244,68 +911,6 @@ describe("ADE CLI", () => { }); }); - it("summarizes mission launch from post-wait mission and graph snapshots", () => { - const connection = { - mode: "headless" as const, - projectRoot: "/tmp/project", - workspaceRoot: "/tmp/project", - socketPath: "/tmp/project/.ade/ade.sock", - request: async () => null, - close: () => {}, - }; - - const summarized = summarizeExecution({ - plan: { kind: "execute", label: "mission launch", steps: [] }, - connection, - values: { - created: { - domain: "mission", - action: "create", - result: { id: "mission-1", status: "queued" }, - }, - started: { - domain: "orchestrator", - action: "startMissionRun", - result: { - started: { run: { id: "run-1", status: "active" } }, - mission: { id: "mission-1", status: "in_progress" }, - }, - }, - mission: { - domain: "mission", - action: "get", - result: { - id: "mission-1", - status: "intervention_required", - lastError: "Model not found", - }, - }, - graph: { - domain: "orchestrator_core", - action: "getRunGraph", - result: { - graph: { - run: { - id: "run-1", - status: "paused", - lastError: "Model not found", - }, - steps: [], - }, - }, - }, - }, - } as any); - - expect(summarized).toMatchObject({ - mission: { - id: "mission-1", - status: "intervention_required", - lastError: "Model not found", - }, - run: { id: "run-1", status: "paused", lastError: "Model not found" }, - }); - }); it("turns ADE action failure envelopes into CLI tool errors", () => { expect(() => diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ff175fba5..2aa1da8f6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -91,11 +91,6 @@ type FormatterId = | "pr-detail" | "pr-checks" | "pr-comments" - | "mission-list" - | "mission-detail" - | "mission-runs" - | "mission-graph" - | "mission-watch" | "run-defs" | "run-runtime" | "chat-list" @@ -207,19 +202,6 @@ const CLI_ENTRY_PATH = typeof process.argv[1] === "string" ? path.resolve(process.argv[1]) : ""; const CLI_PACKAGE_ROOT = resolveCliPackageRoot(CLI_ENTRY_PATH); const CLI_DIST_PATH = path.join(CLI_PACKAGE_ROOT, "dist", "cli.cjs"); -const WORKER_MISSION_TOOL_CLI_NAMES = new Set([ - "get_mission", - "get_run_graph", - "get_worker_states", - "get_timeline", - "get_pending_messages", - "stream_events", - "message_worker", - "report_status", - "report_result", - "report_validation", -]); - function resolveCliPackageRoot(entryPath: string): string { const seen = new Set(); const starts = [entryPath ? path.dirname(entryPath) : null, process.cwd()]; @@ -392,10 +374,9 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade operations status | wait Poll operation/test/chat/run status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces - $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions @@ -439,7 +420,6 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" - $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 @@ -459,9 +439,6 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' Multi-parameter service call: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - Single scalar parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text $ ade actions list --domain pr --text $ ade actions run --input-json '{"key":"value"}' @@ -991,7 +968,7 @@ const HELP_BY_COMMAND: Record = { Operations Poll status for long-running ADE operations that returned an operation, - test run, chat session, run graph, mission, or PR id. + test run, chat session, run graph, or PR id. $ ade operations status --operation --text $ ade operations wait --operation --wait-ms 30000 --text @@ -1056,36 +1033,6 @@ const HELP_BY_COMMAND: Record = { $ ade prs resolve-thread --thread Resolve a review thread $ ade prs labels set ready-to-merge Replace labels $ ade prs reviewers request alice bob Request reviewers -`, - missions: `${ADE_BANNER} - Missions - - Mission commands are the typed CLI surface for backend mission launch, - monitoring, and run inspection. They work in --headless mode for local - service checks, or --socket when you intentionally want the live desktop - drawer/session state. - - $ ade missions list --text List missions - $ ade missions create --prompt "Fix login" Create a mission without starting a run - $ ade missions launch --prompt "Fix login" --manual --text - Create and start a mission run - $ ade missions launch --prompt "..." --wait-ms 30000 --text - Keep headless runtime alive and return graph - $ ade missions start --manual Start an existing mission - $ ade missions resume --text Resume an active/paused run and restart coordinator control - $ ade missions resume --wait-ms 30000 --text - Keep resumed coordinator alive and return graph - $ ade missions show --text Inspect mission detail - $ ade missions runs --text List run attempts for a mission - $ ade missions graph --text Inspect one run graph - $ ade missions watch --text Snapshot mission + newest run graph - $ ade missions watch --wait-ms 5000 --text - Wait before taking the snapshot - - Phase and planner payloads: - $ ade missions create --prompt "..." --phase-override-file phases.json - $ ade missions create --prompt "..." --planned-steps-file steps.json - $ ade missions create --input-json '{"prompt":"...","phaseOverride":[...]}' `, run: `${ADE_BANNER} Run tab @@ -1454,9 +1401,9 @@ const HELP_BY_COMMAND: Record = { coordinator: `${ADE_BANNER} Coordinator runtime tools - Coordinator tools expose orchestration operations used by mission agents. - List tool names with: - $ ade actions call list_ade_actions --input-json '{"domain":"orchestrator_core"}' + Coordinator tools expose orchestration operations used by agent runtimes. + List available tool names with: + $ ade actions list --text $ ade coordinator --input-json '{"key":"value"}' `, @@ -1474,9 +1421,6 @@ const HELP_BY_COMMAND: Record = { $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' argsList is for service methods with multiple positional parameters: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - scalar is for one non-object parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text Domain-grouped action catalog $ ade actions list --domain git --text Narrow the catalog $ ade actions run --input-json '{"key":"value"}' @@ -2225,30 +2169,6 @@ function actionScalarStep( return actionCallStep(key, "run_ade_action", { domain, action, arg }); } -function waitRunGraphStep(args: { - key: string; - runId: string | ((values: JsonObject) => string); - waitMs: number | undefined; - timelineLimit: number; - untilTerminal: boolean; -}): InvocationStep | null { - if ((args.waitMs == null || args.waitMs <= 0) && !args.untilTerminal) - return null; - const waitMs = Math.min( - 30 * 60 * 1000, - Math.max(0, Math.floor(args.waitMs ?? 30 * 60 * 1000)), - ); - return { - key: args.key, - method: "ade-cli/wait-run-graph", - params: (values) => ({ - runId: typeof args.runId === "function" ? args.runId(values) : args.runId, - waitMs, - untilTerminal: args.untilTerminal, - timelineLimit: args.timelineLimit, - }), - }; -} function listActionsStep(key: string, domain?: string): InvocationStep { return actionCallStep(key, "list_ade_actions", domain ? { domain } : {}); @@ -2305,103 +2225,6 @@ function buildActionRunStep(args: string[]): InvocationStep { return actionStep("result", domain, action, collectGenericObjectArgs(args)); } -function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { - const input = (() => { - if (name === "get_mission") { - return collectGenericObjectArgs(args, { - missionId: readValue(args, ["--mission", "--mission-id"]), - }); - } - if (name === "get_run_graph") { - return collectGenericObjectArgs(args, { - runId: readValue(args, ["--run", "--run-id"]), - timelineLimit: readIntOption(args, ["--timeline-limit"], 300), - }); - } - if (name === "get_worker_states") { - return collectGenericObjectArgs(args, { - runId: readValue(args, ["--run", "--run-id"]), - }); - } - if (name === "get_timeline") { - return collectGenericObjectArgs(args, { - runId: readValue(args, ["--run", "--run-id"]), - limit: readIntOption(args, ["--limit"], 300), - stepId: readValue(args, ["--step", "--step-id"]), - }); - } - if (name === "get_pending_messages") { - return collectGenericObjectArgs(args, { - since_cursor: readValue(args, ["--since-cursor", "--since"]), - limit: readIntOption(args, ["--limit"], 50), - }); - } - if (name === "stream_events") { - return collectGenericObjectArgs(args, { - cursor: readIntOption(args, ["--cursor"], 0), - limit: readIntOption(args, ["--limit"], 100), - category: readValue(args, ["--category"]), - }); - } - if (name === "message_worker") { - const toWorkerId = - readValue(args, [ - "--to-worker", - "--to-worker-id", - "--worker", - "--worker-id", - "--to", - ]) ?? firstPositional(args); - const content = - readValue(args, ["--content", "--message", "--body"]) ?? - args - .filter((entry) => entry !== "--" && !entry.startsWith("-")) - .join(" ") - .trim(); - return collectGenericObjectArgs(args, { - fromWorkerId: readValue(args, [ - "--from-worker", - "--from-worker-id", - "--from", - ]), - toWorkerId, - content, - priority: readValue(args, ["--priority"]) ?? "normal", - }); - } - if (name === "report_status") { - 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, { - summary: readValue(args, ["--text", "--message", "--summary"]), - }); - } - return collectGenericObjectArgs(args); - })(); - return { - kind: "execute", - label: `worker mission tool ${name}`, - formatter: "action-result", - steps: [actionCallStep("result", name, input)], - }; -} - function buildLanePlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "actions") { @@ -4280,503 +4103,6 @@ function buildPrPlan(args: string[]): CliPlan { }; } -function collectMissionCreateArgs( - args: string[], - base: JsonObject = {}, -): JsonObject { - const noAutostart = readFlag(args, ["--no-autostart", "--no-start"]); - const autostartFlag = readFlag(args, ["--autostart"]); - const manual = readFlag(args, ["--manual"]); - const prompt = readValue(args, ["--prompt", "--message"]); - const createBase: JsonObject = { ...base }; - if (noAutostart) createBase.autostart = false; - if (autostartFlag) createBase.autostart = true; - if (manual) createBase.launchMode = "manual"; - const input = collectGenericObjectArgs(args, { - ...createBase, - ...(prompt ? { prompt } : {}), - title: readValue(args, ["--title"]), - laneId: readLaneId(args), - priority: readValue(args, ["--priority"]), - executionMode: readValue(args, ["--execution-mode"]), - targetMachineId: readValue(args, [ - "--target-machine", - "--target-machine-id", - ]), - plannerEngine: readValue(args, ["--planner", "--planner-engine"]), - planningTimeoutMs: readIntOption(args, ["--planning-timeout-ms"]), - launchMode: - readValue(args, ["--launch-mode", "--run-mode"]) ?? createBase.launchMode, - autopilotExecutor: readValue(args, [ - "--executor", - "--autopilot-executor", - "--default-executor", - ]), - autostart: createBase.autostart, - phaseProfileId: readValue(args, ["--phase-profile", "--phase-profile-id"]), - employeeAgentId: readValue(args, [ - "--employee-agent", - "--employee-agent-id", - ]), - }); - - const phaseOverride = readJsonPayloadOption( - args, - ["--phase-override-json"], - ["--phase-override-file"], - "--phase-override-json", - ); - if (phaseOverride !== undefined) { - if (!Array.isArray(phaseOverride)) - throw new CliUsageError("--phase-override-json must be a JSON array."); - input.phaseOverride = phaseOverride; - } - - const plannedSteps = readJsonPayloadOption( - args, - ["--planned-steps-json"], - ["--planned-steps-file"], - "--planned-steps-json", - ); - if (plannedSteps !== undefined) { - if (!Array.isArray(plannedSteps)) - throw new CliUsageError("--planned-steps-json must be a JSON array."); - input.plannedSteps = plannedSteps; - } - - const jsonObjects: Array<[string, string[], string[], string]> = [ - [ - "modelConfig", - ["--model-config-json"], - ["--model-config-file"], - "--model-config-json", - ], - [ - "executionPolicy", - ["--execution-policy-json"], - ["--execution-policy-file"], - "--execution-policy-json", - ], - [ - "recoveryLoop", - ["--recovery-loop-json"], - ["--recovery-loop-file"], - "--recovery-loop-json", - ], - [ - "teamRuntime", - ["--team-runtime-json"], - ["--team-runtime-file"], - "--team-runtime-json", - ], - [ - "agentRuntime", - ["--agent-runtime-json"], - ["--agent-runtime-file"], - "--agent-runtime-json", - ], - [ - "permissionConfig", - ["--permission-config-json"], - ["--permission-config-file"], - "--permission-config-json", - ], - ]; - for (const [key, inlineNames, fileNames, label] of jsonObjects) { - const value = readJsonPayloadOption(args, inlineNames, fileNames, label); - if (value === undefined) continue; - if (!isRecord(value)) - throw new CliUsageError(`${label} must be a JSON object.`); - input[key] = value; - } - - if (!asString(input.prompt)) { - const positionalPrompt = args - .filter((entry) => entry !== "--" && !entry.startsWith("-")) - .join(" ") - .trim(); - if (positionalPrompt.length > 0) input.prompt = positionalPrompt; - } - input.prompt = requireValue(asString(input.prompt) ?? null, "prompt"); - return input; -} - -function collectMissionStartArgs( - args: string[], - base: JsonObject = {}, -): JsonObject { - const manual = readFlag(args, ["--manual"]); - const runMode = manual - ? "manual" - : readValue(args, ["--run-mode", "--launch-mode"]); - const executor = readValue(args, [ - "--executor", - "--default-executor", - "--executor-kind", - ]); - const owner = readValue(args, ["--owner", "--owner-id", "--autopilot-owner"]); - const input: JsonObject = { ...base }; - if (runMode) input.runMode = runMode; - if (executor ?? base.defaultExecutorKind) - input.defaultExecutorKind = executor ?? base.defaultExecutorKind; - if (owner) input.autopilotOwnerId = owner; - return collectGenericObjectArgs(args, input); -} - -function buildMissionsPlan(args: string[]): CliPlan { - const sub = firstPositional(args) ?? "list"; - if (sub === "actions") - return { - kind: "execute", - label: "mission actions", - steps: [listActionsStep("actions", "mission")], - }; - if (sub === "action") - return { - kind: "execute", - label: "mission action", - steps: [buildActionRunStep(["mission", ...args])], - }; - - if (sub === "list" || sub === "ls") { - return { - kind: "execute", - label: "mission list", - formatter: "mission-list", - steps: [ - actionStep( - "result", - "mission", - "list", - collectGenericObjectArgs(args, { - status: readValue(args, ["--status"]), - laneId: readLaneId(args), - limit: readIntOption(args, ["--limit"]), - includeArchived: readFlag(args, ["--include-archived"]), - }), - ), - ], - }; - } - - if (sub === "create" || sub === "new") { - return { - kind: "execute", - label: "mission create", - formatter: "mission-detail", - steps: [ - actionStep( - "result", - "mission", - "create", - collectMissionCreateArgs(args), - ), - ], - }; - } - - if (sub === "launch") { - const waitUntilTerminal = readFlag(args, [ - "--wait", - "--until-terminal", - "--wait-until-terminal", - ]); - const waitMs = readIntOption( - args, - ["--wait-ms", "--hold-ms", "--wait-for-ms"], - waitUntilTerminal ? 30 * 60 * 1000 : undefined, - ); - const timelineLimit = readIntOption(args, ["--timeline-limit"], 120) ?? 120; - const createArgs = collectMissionCreateArgs(args, { autostart: false }); - const startArgs = collectMissionStartArgs(args, { - runMode: createArgs.launchMode === "manual" ? "manual" : undefined, - defaultExecutorKind: createArgs.autopilotExecutor, - }); - const waitGraphStep = waitRunGraphStep({ - key: "graph", - runId: (values) => - requireValue( - asString(runFromStartResult(values.started)?.id) ?? null, - "run id", - ), - waitMs, - untilTerminal: waitUntilTerminal, - timelineLimit, - }); - const steps: InvocationStep[] = [ - actionStep("created", "mission", "create", createArgs), - { - ...actionStep("started", "orchestrator", "startMissionRun", {}), - params: (values) => ({ - name: "run_ade_action", - arguments: { - domain: "orchestrator", - action: "startMissionRun", - args: { - ...startArgs, - missionId: missionIdFromCreateResult(values.created), - }, - }, - }), - }, - ]; - if (waitGraphStep) { - steps.push({ - ...actionScalarStep("mission", "mission", "get", ""), - optional: true, - params: (values) => ({ - name: "run_ade_action", - arguments: { - domain: "mission", - action: "get", - arg: missionIdFromCreateResult(values.created), - }, - }), - }); - steps.push(waitGraphStep); - } - return { - kind: "execute", - label: "mission launch", - formatter: "mission-watch", - steps, - }; - } - - if (sub === "start" || sub === "run") { - const missionId = requireValue( - readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), - "missionId", - ); - return { - kind: "execute", - label: "mission start", - formatter: "mission-watch", - steps: [ - actionStep( - "result", - "orchestrator", - "startMissionRun", - collectMissionStartArgs(args, { missionId }), - ), - ], - }; - } - - if (sub === "show" || sub === "get" || sub === "view") { - const missionId = requireValue( - readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), - "missionId", - ); - return { - kind: "execute", - label: "mission show", - formatter: "mission-detail", - steps: [actionScalarStep("result", "mission", "get", missionId)], - }; - } - - if (sub === "runs" || sub === "attempts") { - const missionId = - readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args); - return { - kind: "execute", - label: "mission runs", - formatter: "mission-runs", - steps: [ - actionStep( - "result", - "orchestrator_core", - "listRuns", - collectGenericObjectArgs(args, { - missionId: missionId ?? undefined, - status: readValue(args, ["--status"]), - limit: readIntOption(args, ["--limit"], 20), - }), - ), - ], - }; - } - - if (sub === "graph" || sub === "run-graph") { - const runId = requireValue( - readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), - "runId", - ); - return { - kind: "execute", - label: "mission graph", - formatter: "mission-graph", - steps: [ - actionStep( - "result", - "orchestrator_core", - "getRunGraph", - collectGenericObjectArgs(args, { - runId, - timelineLimit: readIntOption(args, ["--timeline-limit"], 80), - }), - ), - ], - }; - } - - if (sub === "watch" || sub === "monitor") { - const waitUntilTerminal = readFlag(args, [ - "--wait", - "--until-terminal", - "--wait-until-terminal", - ]); - const waitMs = readIntOption( - args, - ["--wait-ms", "--hold-ms", "--wait-for-ms"], - waitUntilTerminal ? 30 * 60 * 1000 : undefined, - ); - const runId = readValue(args, ["--run", "--run-id"]); - const missionId = - readValue(args, ["--mission", "--mission-id"]) ?? - (runId ? null : firstPositional(args)); - const timelineLimit = readIntOption(args, ["--timeline-limit"], 80) ?? 80; - const steps: InvocationStep[] = []; - if (missionId) { - steps.push(actionScalarStep("mission", "mission", "get", missionId)); - steps.push( - actionStep("runs", "orchestrator_core", "listRuns", { - missionId, - limit: readIntOption(args, ["--limit"], 20), - }), - ); - } - const waitGraphStep = waitRunGraphStep({ - key: "graph", - runId: (values) => runId ?? runIdFromWatchValues(values), - waitMs, - untilTerminal: waitUntilTerminal, - timelineLimit, - }); - if (waitGraphStep) { - steps.push(waitGraphStep); - } else { - steps.push({ - ...actionStep("graph", "orchestrator_core", "getRunGraph", {}), - optional: !runId, - params: (values) => ({ - name: "run_ade_action", - arguments: { - domain: "orchestrator_core", - action: "getRunGraph", - args: { - runId: runId ?? runIdFromWatchValues(values), - timelineLimit, - }, - }, - }), - }); - } - return { - kind: "execute", - label: "mission watch", - formatter: "mission-watch", - steps, - }; - } - - if (sub === "pause") { - const runId = requireValue( - readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), - "runId", - ); - return { - kind: "execute", - label: "mission pause", - formatter: "mission-graph", - steps: [ - actionStep( - "result", - "orchestrator_core", - "pauseRun", - collectGenericObjectArgs(args, { - runId, - reason: readValue(args, ["--reason"]), - }), - ), - ], - }; - } - - if (sub === "resume") { - const runId = requireValue( - readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), - "runId", - ); - const waitUntilTerminal = readFlag(args, [ - "--wait", - "--until-terminal", - "--wait-until-terminal", - ]); - const waitMs = readIntOption( - args, - ["--wait-ms", "--hold-ms", "--wait-for-ms"], - waitUntilTerminal ? 30 * 60 * 1000 : undefined, - ); - const steps: InvocationStep[] = [ - actionStep( - "result", - "orchestrator", - "resumeRun", - collectGenericObjectArgs(args, { runId }), - ), - ]; - const waitGraphStep = waitRunGraphStep({ - key: "graph", - runId, - waitMs, - untilTerminal: waitUntilTerminal, - timelineLimit: readIntOption(args, ["--timeline-limit"], 120) ?? 120, - }); - if (waitGraphStep) steps.push(waitGraphStep); - return { - kind: "execute", - label: "mission resume", - formatter: waitGraphStep ? "mission-watch" : "mission-graph", - steps, - }; - } - - if (sub === "cancel") { - const runId = requireValue( - readValue(args, ["--run", "--run-id"]) ?? - readValue(args, ["--mission", "--mission-id"]) ?? - firstPositional(args), - "runId", - ); - return { - kind: "execute", - label: "mission cancel", - formatter: "mission-detail", - steps: [ - actionStep( - "result", - "orchestrator", - "cancelRunGracefully", - collectGenericObjectArgs(args, { - runId, - reason: readValue(args, ["--reason"]), - }), - ), - ], - }; - } - - return { - kind: "execute", - label: `mission ${sub}`, - steps: [ - actionStep("result", "mission", sub, collectGenericObjectArgs(args)), - ], - }; -} - function buildRunPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "ps"; if (sub === "actions") @@ -7801,7 +7127,6 @@ function buildActionStatusArgs( readValue(args, ["--chat-session", "--chat-session-id"]), ); maybePut(input, "runId", readValue(args, ["--run", "--run-id"])); - maybePut(input, "missionId", readValue(args, ["--mission", "--mission-id"])); maybePut(input, "prId", readValue(args, ["--pr", "--pr-id"])); maybePut(input, "previousHash", readValue(args, ["--previous-hash"])); maybePut( @@ -8589,12 +7914,11 @@ function buildLinearPlan(args: string[]): CliPlan { const mode = firstPositional(args) ?? "cto"; const toolByMode: Record = { cto: "routeLinearIssueToCto", - mission: "routeLinearIssueToMission", worker: "routeLinearIssueToWorker", }; const tool = toolByMode[mode]; if (!tool) - throw new CliUsageError("linear route supports cto, mission, or worker."); + throw new CliUsageError("linear route supports cto or worker."); return { kind: "execute", label: `Linear route ${mode}`, @@ -9032,7 +8356,6 @@ function buildCliPlan(command: string[]): CliPlan { diff: "diff", diffs: "diff", file: "files", - mission: "missions", pr: "prs", process: "run", processes: "run", @@ -9191,15 +8514,10 @@ function buildCliPlan(command: string[]): CliPlan { ], }; } - if (WORKER_MISSION_TOOL_CLI_NAMES.has(primary)) { - return buildWorkerMissionToolPlan(primary, args); - } if (primary === "lanes" || primary === "lane") return buildLanePlan(args); if (primary === "git") return buildGitPlan(args); if (primary === "diff" || primary === "diffs") return buildDiffPlan(args); if (primary === "files" || primary === "file") return buildFilesPlan(args); - if (primary === "missions" || primary === "mission") - return buildMissionsPlan(args); if (primary === "prs" || primary === "pr") return buildPrPlan(args); if (primary === "run" || primary === "process" || primary === "processes") return buildRunPlan(args); @@ -10698,7 +10016,6 @@ function buildInitializeParams( clientName: string, ): JsonObject { const envChatSessionId = asString(process.env.ADE_CHAT_SESSION_ID); - const envMissionId = asString(process.env.ADE_MISSION_ID); const envRunId = asString(process.env.ADE_RUN_ID); const envStepId = asString(process.env.ADE_STEP_ID); const envAttemptId = asString(process.env.ADE_ATTEMPT_ID); @@ -10711,7 +10028,6 @@ function buildInitializeParams( envChatSessionId ?? envAttemptId ?? `${clientName}:${process.pid}`, role: options.role, ...(envChatSessionId ? { chatSessionId: envChatSessionId } : {}), - ...(envMissionId ? { missionId: envMissionId } : {}), ...(envRunId ? { runId: envRunId } : {}), ...(envStepId ? { stepId: envStepId } : {}), ...(envAttemptId ? { attemptId: envAttemptId } : {}), @@ -11619,47 +10935,6 @@ function unwrapActionEnvelope(value: unknown): unknown { return value; } -function missionIdFromCreateResult(value: unknown): string { - const result = unwrapActionEnvelope(value); - const mission = firstRecord(result, ["mission"]); - const id = - asString(mission?.id) ?? (isRecord(result) ? asString(result.id) : null); - return requireValue(id ?? null, "created mission id"); -} - -function newestRunFromListResult(value: unknown): JsonObject | null { - const result = unwrapActionEnvelope(value); - const runs = firstArray(result, ["runs", "items", "results"]); - if (runs.length === 0) return null; - return ( - [...runs].sort((left, right) => { - const leftAt = asString(left.startedAt) ?? asString(left.createdAt) ?? ""; - const rightAt = - asString(right.startedAt) ?? asString(right.createdAt) ?? ""; - return rightAt.localeCompare(leftAt); - })[0] ?? null - ); -} - -function runFromStartResult(value: unknown): JsonObject | null { - const result = unwrapActionEnvelope(value); - const direct = firstRecord(result, ["run"]); - if (direct && asString(direct.id)) return direct; - const started = firstRecord(result, ["started"]); - const nested = firstRecord(started, ["run"]); - if (nested && asString(nested.id)) return nested; - if (started && asString(started.id)) return started; - return null; -} - -function missionFromResult(value: unknown): JsonObject | null { - const result = unwrapActionEnvelope(value); - const mission = firstRecord(result, ["mission"]); - if (mission && asString(mission.id)) return mission; - if (isRecord(result) && asString(result.id)) return result; - return null; -} - function graphFromResult(value: unknown): JsonObject | null { const result = unwrapActionEnvelope(value); if (!isRecord(result)) return null; @@ -11694,8 +10969,7 @@ function runIdFromWatchValues(values: JsonObject): string { const graphRunId = asString(graphRun?.id); if (graphRunId) return graphRunId; } - const run = newestRunFromListResult(values.runs); - return requireValue(asString(run?.id) ?? null, "run id"); + return requireValue(null, "run id"); } function renderLaneGraph(result: unknown): string { @@ -12107,184 +11381,6 @@ function formatPrComments(value: unknown): string { return lines.join("\n"); } -function phaseKeysFromMission(mission: JsonObject): string { - const metadata = isRecord(mission.metadata) ? mission.metadata : {}; - const phaseConfiguration = isRecord(metadata.phaseConfiguration) - ? metadata.phaseConfiguration - : {}; - const phaseKeys = Array.isArray(phaseConfiguration.phaseKeys) - ? phaseConfiguration.phaseKeys - : Array.isArray(phaseConfiguration.phases) - ? phaseConfiguration.phases - .filter(isRecord) - .map((phase) => phase.phaseKey) - : []; - return phaseKeys - .map((key) => cell(key, 24)) - .filter(Boolean) - .join(" -> "); -} - -function formatMissionDetail(value: unknown): string { - const result = unwrapActionEnvelope(value); - const mission = - firstRecord(result, ["mission"]) ?? (isRecord(result) ? result : {}); - const steps = firstArray(mission, ["steps"]); - const phaseKeys = phaseKeysFromMission(mission); - return [ - renderKeyValues("ADE mission", [ - ["id", mission.id], - ["title", mission.title], - ["status", mission.status], - ["priority", mission.priority], - ["lane", mission.laneId ?? mission.laneName], - ["mission lane", mission.missionLaneId ?? mission.missionLaneName], - ["result lane", mission.resultLaneId ?? mission.resultLaneName], - ["steps", steps.length || mission.totalSteps], - ["phases", phaseKeys], - ["error", mission.lastError], - ]), - steps.length - ? `\nSteps\n${renderTable( - ["#", "status", "phase", "title"], - steps.map((step) => [ - step.index ?? step.stepIndex, - step.status, - step.phaseKey ?? - (isRecord(step.metadata) ? step.metadata.phaseKey : null), - step.title, - ]), - "(no steps)", - )}` - : "", - ] - .filter(Boolean) - .join("\n"); -} - -function formatMissionList(value: unknown): string { - const result = unwrapActionEnvelope(value); - const missions = firstArray(result, ["missions", "items", "results"]); - return `ADE missions\n${renderTable( - ["mission", "status", "lane", "steps", "title"], - missions.map((mission) => [ - mission.id, - mission.status, - mission.laneId ?? mission.laneName ?? mission.missionLaneId, - `${cell(mission.completedSteps, 8)}/${cell(mission.totalSteps, 8)}`, - mission.title, - ]), - "(no missions)", - )}`; -} - -function formatMissionRuns(value: unknown): string { - const result = unwrapActionEnvelope(value); - const runs = firstArray(result, ["runs", "items", "results"]); - return `ADE mission runs\n${renderTable( - ["run", "status", "mission", "mode", "started"], - runs.map((run) => { - const metadata = isRecord(run.metadata) ? run.metadata : {}; - return [ - run.id, - run.status, - run.missionId, - metadata.runMode ?? run.runMode, - run.startedAt ?? run.createdAt, - ]; - }), - "(no runs)", - )}`; -} - -function formatMissionGraph(value: unknown): string { - const result = unwrapActionEnvelope(value); - const graph = - isRecord(result) && isRecord(result.graph) ? result.graph : result; - const run = firstRecord(graph, ["run"]) ?? {}; - const steps = firstArray(graph, ["steps"]); - const attempts = firstArray(graph, ["attempts"]); - const timeline = firstArray(graph, ["timeline", "events"]); - return [ - renderKeyValues("ADE mission run graph", [ - ["run", run.id], - ["mission", run.missionId], - ["status", run.status], - ["steps", steps.length], - ["attempts", attempts.length], - ["timeline events", timeline.length], - ["started", run.startedAt ?? run.createdAt], - ["error", run.lastError], - ]), - steps.length - ? `\nSteps\n${renderTable( - ["step", "status", "phase", "title"], - steps.map((step) => [ - step.id ?? step.stepKey, - step.status, - step.phaseKey ?? - (isRecord(step.metadata) ? step.metadata.phaseKey : null), - step.title, - ]), - "(no steps)", - )}` - : "", - ] - .filter(Boolean) - .join("\n"); -} - -function formatMissionWatch(value: unknown): string { - const result = isRecord(value) ? value : {}; - const created = unwrapActionEnvelope(result.created); - const started = unwrapActionEnvelope(result.started ?? result.result); - const mission = - missionFromResult(result.mission) ?? - missionFromResult(created) ?? - missionFromResult(started) ?? - {}; - const runsResult = unwrapActionEnvelope(result.runs); - const newestRun = - newestRunFromListResult(runsResult) ?? runFromStartResult(started); - const graphResult = unwrapActionEnvelope(result.graph); - const graph = graphFromResult(graphResult) ?? {}; - const wait = firstRecord(graphResult, ["wait"]); - const graphRun = runFromGraphResult(graphResult) ?? newestRun ?? {}; - const graphSteps = firstArray(graph, ["steps"]); - const parts = [ - renderKeyValues("ADE mission watch", [ - ["mission", mission.id], - ["title", mission.title], - ["mission status", mission.status], - ["run", graphRun.id], - ["run status", graphRun.status], - ["steps", graphSteps.length || mission.totalSteps], - ["mission lane", mission.missionLaneId ?? mission.missionLaneName], - ["result lane", mission.resultLaneId ?? mission.resultLaneName], - ["wait timed out", wait?.timedOut], - ["wait extended", wait?.extendedForActiveHeadlessWork], - ["error", mission.lastError ?? graphRun.lastError], - ]), - ]; - if (graphSteps.length > 0) { - parts.push( - "", - renderTable( - ["step", "status", "phase", "title"], - graphSteps.map((step) => [ - step.id ?? step.stepKey, - step.status, - step.phaseKey ?? - (isRecord(step.metadata) ? step.metadata.phaseKey : null), - step.title, - ]), - "(no steps)", - ), - ); - } - return parts.join("\n"); -} - function formatFileTree(value: unknown): string { const entries = firstArray(value, ["entries", "nodes", "items", "children"]); return renderTable( @@ -13249,16 +12345,6 @@ function formatTextOutput( return formatPrChecks(value); case "pr-comments": return formatPrComments(value); - case "mission-list": - return formatMissionList(value); - case "mission-detail": - return formatMissionDetail(value); - case "mission-runs": - return formatMissionRuns(value); - case "mission-graph": - return formatMissionGraph(value); - case "mission-watch": - return formatMissionWatch(value); case "run-defs": return formatRunTable(value, "ADE run definitions"); case "run-runtime": @@ -13485,49 +12571,6 @@ function summarizeExecution(args: { }; } - if (plan.label === "mission launch") { - const created = unwrapActionEnvelope(values.created); - const started = unwrapActionEnvelope(values.started); - const refreshedMission = missionFromResult(values.mission); - const graph = unwrapActionEnvelope(values.graph); - return { - created, - started, - mission: - refreshedMission ?? - missionFromResult(started) ?? - missionFromResult(created) ?? - created, - run: runFromGraphResult(graph) ?? runFromStartResult(started), - graph, - }; - } - - if (plan.label === "mission watch") { - return { - mission: unwrapActionEnvelope(values.mission), - runs: unwrapActionEnvelope(values.runs), - graph: unwrapActionEnvelope(values.graph), - }; - } - - if (plan.label === "mission resume") { - const graph = unwrapActionEnvelope(values.graph); - if (graph) { - return { - run: runFromGraphResult(graph), - graph, - }; - } - const resumed = unwrapActionEnvelope(values.result); - return { - run: resumed, - steps: [], - attempts: [], - timeline: [], - }; - } - if (plan.label === "PR create") { return summarizePrCreateResult(values.result ?? values); } @@ -13590,14 +12633,6 @@ function summarizeExecution(args: { return result; } -const TERMINAL_MISSION_RUN_STATUSES = new Set([ - "succeeded", - "failed", - "canceled", - "cancelled", -]); -const HEADLESS_ACTIVE_ATTEMPT_DRAIN_MS = 30 * 60 * 1000; - function graphWaitState(value: unknown): { status: string; activeCount: number; @@ -13619,98 +12654,13 @@ function graphWaitState(value: unknown): { }; } -async function requestRunGraph(args: { - connection: CliConnection; - runId: string; - timelineLimit: number; -}): Promise { - return await args.connection.request("ade/actions/call", { - name: "run_ade_action", - arguments: { - domain: "orchestrator_core", - action: "getRunGraph", - args: { - runId: args.runId, - timelineLimit: args.timelineLimit, - }, - }, - }); -} - -async function waitForRunGraph(args: { - connection: CliConnection; - runId: string; - waitMs: number; - timelineLimit: number; - untilTerminal: boolean; -}): Promise { - const startedAt = Date.now(); - const deadline = startedAt + Math.max(0, args.waitMs); - const headlessDrainDeadline = deadline + HEADLESS_ACTIVE_ATTEMPT_DRAIN_MS; - let raw: unknown = null; - let timedOut = false; - let extendedForActiveHeadlessWork = false; - - while (true) { - raw = await requestRunGraph({ - connection: args.connection, - runId: args.runId, - timelineLimit: args.timelineLimit, - }); - const unwrapped = unwrapActionEnvelope(raw); - const waitState = graphWaitState(unwrapped); - const terminal = TERMINAL_MISSION_RUN_STATUSES.has(waitState.status); - if (terminal) break; - - const now = Date.now(); - const pastDeadline = now >= deadline; - if (pastDeadline) { - timedOut = true; - const shouldDrainActiveHeadlessWork = - args.connection.mode === "headless" && - waitState.activeCount > 0 && - now < headlessDrainDeadline; - if (!shouldDrainActiveHeadlessWork) break; - extendedForActiveHeadlessWork = true; - } - - await sleep(1_000); - } - - const graph = graphFromResult(raw) ?? {}; - const waitState = graphWaitState(raw); - return { - graph, - wait: { - runId: args.runId, - waitedMs: Math.max(0, Date.now() - startedAt), - requestedWaitMs: args.waitMs, - untilTerminal: args.untilTerminal, - timedOut, - extendedForActiveHeadlessWork, - mode: args.connection.mode, - runStatus: waitState.status || null, - activeCount: waitState.activeCount, - }, - }; -} - async function executePlan( plan: CliPlan & { kind: "execute" }, options: GlobalOptions, ): Promise { let connection: CliConnection; - const isWorkerMissionToolPlan = plan.label.startsWith("worker mission tool "); - const workerRpcUrl = process.env.ADE_RPC_URL?.trim(); - const workerSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); const connectionOptions = - isWorkerMissionToolPlan && !options.requireSocket - ? { - ...options, - headless: false, - requireSocket: Boolean(workerRpcUrl || workerSocketOverride), - } - : plan.preferHeadless && !options.requireSocket + plan.preferHeadless && !options.requireSocket ? { ...options, headless: true } : options; try { @@ -13756,29 +12706,6 @@ async function executePlan( try { const params = typeof step.params === "function" ? step.params(values) : step.params; - if (step.method === "ade-cli/wait-run-graph") { - const runId = requireValue(asString(params?.runId) ?? null, "run id"); - const waitMs = Math.max( - 0, - Math.floor(typeof params?.waitMs === "number" ? params.waitMs : 0), - ); - const timelineLimit = Math.max( - 0, - Math.floor( - typeof params?.timelineLimit === "number" - ? params.timelineLimit - : 120, - ), - ); - values[step.key] = await waitForRunGraph({ - connection, - runId, - waitMs, - timelineLimit, - untilTerminal: params?.untilTerminal === true, - }); - continue; - } const raw = await connection.request(step.method, params); values[step.key] = step.unwrapToolResult ? unwrapToolResult(raw) : raw; } catch (error) { diff --git a/apps/ade-cli/src/eventBuffer.ts b/apps/ade-cli/src/eventBuffer.ts index 93e025687..39715256c 100644 --- a/apps/ade-cli/src/eventBuffer.ts +++ b/apps/ade-cli/src/eventBuffer.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; export type BufferedEvent = { id: number; timestamp: string; - category: "orchestrator" | "dag_mutation" | "runtime" | "mission" | "pty"; + category: "orchestrator" | "dag_mutation" | "runtime" | "pty"; payload: Record; }; diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 2fa309990..274ad24ba 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -115,7 +115,6 @@ function createDeps() { chatSessionsDir: "/tmp/ade-project/.ade/chats/sessions", chatTranscriptsDir: "/tmp/ade-project/.ade/chats/transcripts", orchestratorCacheDir: "/tmp/ade-project/.ade/cache/orchestrator", - missionStateDir: "/tmp/ade-project/.ade/missions", }, projectId: "project-1", db: {} as any, @@ -124,9 +123,6 @@ function createDeps() { laneService: {} as any, operationService: {} as any, conflictService: {} as any, - missionService: {} as any, - orchestratorService: {} as any, - aiOrchestratorService: {} as any, workerAgentService: {} as any, workerBudgetService: {} as any, computerUseArtifactBrokerService: {} as any, diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index ead387cd8..bc406e2c6 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -6,9 +6,6 @@ import type { createLaneService } from "../../desktop/src/main/services/lanes/la import type { createOperationService } from "../../desktop/src/main/services/history/operationService"; import type { createProjectConfigService } from "../../desktop/src/main/services/config/projectConfigService"; import type { createConflictService } from "../../desktop/src/main/services/conflicts/conflictService"; -import type { createMissionService } from "../../desktop/src/main/services/missions/missionService"; -import type { createAiOrchestratorService } from "../../desktop/src/main/services/orchestrator/aiOrchestratorService"; -import type { createOrchestratorService } from "../../desktop/src/main/services/orchestrator/orchestratorService"; import type { createWorkerAgentService } from "../../desktop/src/main/services/cto/workerAgentService"; import type { createWorkerBudgetService } from "../../desktop/src/main/services/cto/workerBudgetService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; @@ -221,9 +218,6 @@ type HeadlessLinearDeps = { laneService: ReturnType; operationService: ReturnType; conflictService: ReturnType; - missionService: ReturnType; - orchestratorService: ReturnType; - aiOrchestratorService: ReturnType; workerAgentService: ReturnType; workerBudgetService: ReturnType; computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; @@ -1651,19 +1645,15 @@ export function createHeadlessLinearServices( const closeoutService = createLinearCloseoutServiceImpl({ issueTracker, outboundService, - missionService: args.missionService, - orchestratorService: args.orchestratorService, prService, computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, - }); + } as Parameters[0]); const dispatcherService = createLinearDispatcherServiceImpl({ db: args.db, projectId: args.projectId, issueTracker, workerAgentService: args.workerAgentService, workerHeartbeatService, - missionService: args.missionService, - aiOrchestratorService: args.aiOrchestratorService, agentChatService: agentChatService as never, laneService: args.laneService, templateService, @@ -1672,7 +1662,7 @@ export function createHeadlessLinearServices( workerTaskSessionService, prService, onEvent: args.onLinearWorkflowEvent ?? (() => {}), - }); + } as Parameters[0]); const syncService = createLinearSyncServiceImpl({ db: args.db, logger: args.logger, diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 3f43417d1..1eca3bb11 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -400,7 +400,7 @@ describe("multi-project RPC server", () => { }); eventBuffer.push({ timestamp: "2026-05-10T00:00:01.000Z", - category: "mission", + category: "orchestrator", payload: { type: "ignored" }, }); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 723814baf..de57bab40 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -206,7 +206,6 @@ function readEventCategory(value: unknown): RuntimeEventCategory | null { return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || - value === "mission" || value === "pty" ? value : null; diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 92418e141..083140baf 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -40,7 +40,6 @@ import type { createLaneTemplateService } from "../../../../desktop/src/main/ser import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; -import type { createMissionService } from "../../../../desktop/src/main/services/missions/missionService"; import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; @@ -100,7 +99,6 @@ type SyncServiceArgs = { computerUseArtifactBrokerService: ReturnType< typeof createComputerUseArtifactBrokerService >; - missionService: ReturnType; agentChatService: ReturnType; workerAgentService?: ReturnType | null; workerBudgetService?: ReturnType | null; @@ -129,7 +127,7 @@ type SyncServiceArgs = { onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; /** * Optional notification bus forwarded to the sync host. The host publishes - * chat/PR/mission/system events and invokes `sendInAppNotification` for + * chat/PR/system events and invokes `sendInAppNotification` for * connected iOS peers. */ notificationEventBus?: NotificationEventBus | null; @@ -812,18 +810,6 @@ export function createSyncService(args: SyncServiceArgs) { const computeTransferReadiness = async (): Promise => { const blockers: SyncTransferBlocker[] = []; - for (const mission of args.missionService.list({ - status: "active", - limit: 200, - })) { - blockers.push({ - kind: "mission_run", - id: mission.id, - label: mission.title || mission.id, - detail: `Mission is ${mission.status}. Paused missions can transfer, but active mission work cannot.`, - }); - } - const chats = await args.agentChatService.listSessions(undefined, { includeIdentity: true, includeAutomation: true, @@ -878,7 +864,6 @@ export function createSyncService(args: SyncServiceArgs) { ready: blockers.length === 0, blockers, survivableState: [ - "Paused missions remain paused and can resume on the new host.", "CTO history and idle threads remain available on the new host.", "Idle and ended agent chats remain available and resumable on the new host.", ], @@ -987,7 +972,7 @@ export function createSyncService(args: SyncServiceArgs) { : "Machine sync is disabled because the CRDT database extension is unavailable on this platform.", blockingStateText: crdtSyncAvailable - ? "Live missions, chats, terminals, or run processes must stop first." + ? "Live chats, terminals, or run processes must stop first." : "Install Windows cr-sqlite support before pairing or syncing devices.", }; }, @@ -1119,7 +1104,7 @@ export function createSyncService(args: SyncServiceArgs) { if (current.role === "brain") return current; if (!current.transferReadiness.ready) { throw new Error( - "Stop live missions, chats, terminals, and run processes before transferring the host role.", + "Stop live chats, terminals, and run processes before transferring the host role.", ); } const localDevice = deviceRegistryService.ensureLocalDevice(); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 13355ec04..1fc9f66b9 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -285,8 +285,6 @@ const DESKTOP_COMMAND_ROUTES: Record = { "/ios": "/ios-sim", "/ios-sim": "/ios-sim", "/macos-vm": "/macos-vm", - "/mission": "/missions", - "/missions": "/missions", "/pencil": "/pencil", "/proof": "/proof", }; diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index db0a0c22c..b9f7ac45f 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -56,7 +56,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/linear list", description: "List Linear work", placement: "right" }, { name: "/linear workflows", description: "List Linear workflow runs", placement: "right" }, { name: "/linear run", description: "Inspect or resolve a Linear run", placement: "right", argumentHint: "" }, - { name: "/linear route", description: "Route a Linear issue", placement: "right", argumentHint: "" }, + { name: "/linear route", description: "Route a Linear issue", placement: "right", argumentHint: "" }, { name: "/linear sync", description: "Operate Linear sync", placement: "right", argumentHint: "" }, { name: "/linear ingress", description: "Inspect Linear ingress", placement: "right", argumentHint: "" }, { name: "/linear pull", description: "Pull a Linear ticket into chat context", placement: "right", argumentHint: "" }, diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts index 24d34158d..f778fd456 100644 --- a/apps/ade-cli/src/tuiClient/linearCommands.ts +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -121,7 +121,7 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { if (mode === "reroute") { const target = optionString(options, "target") ?? rest[1] ?? null; const reason = optionString(options, "reason") ?? rest.slice(2).join(" "); - if (!runId || !target || !reason) return usage("Linear run reroute", "Usage: /linear run reroute --reason "); + if (!runId || !target || !reason) return usage("Linear run reroute", "Usage: /linear run reroute --reason "); return tool("Linear run reroute", "rerouteLinearRun", { runId, target, @@ -140,7 +140,7 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { if (group === "route") { const mode = modeArg ?? "cto"; const issueId = optionString(options, "issueId", "issue") ?? rest[0] ?? null; - if (!issueId) return usage("Linear route", "Usage: /linear route "); + if (!issueId) return usage("Linear route", "Usage: /linear route "); if (mode === "cto") { return tool("Linear route cto", "routeLinearIssueToCto", { issueId, @@ -148,14 +148,6 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { reuseExisting: optionBoolean(options, "reuseExisting"), }); } - if (mode === "mission") { - return tool("Linear route mission", "routeLinearIssueToMission", { - issueId, - laneId: optionString(options, "laneId", "lane") ?? undefined, - launch: optionBoolean(options, "launch"), - runMode: optionString(options, "runMode") ?? undefined, - }); - } if (mode === "worker") { const agentId = optionString(options, "agentId", "agent") ?? rest[1] ?? null; if (!agentId) return usage("Linear route worker", "Usage: /linear route worker "); @@ -165,7 +157,7 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { taskKey: optionString(options, "taskKey") ?? undefined, }); } - return usage("Linear route", "Usage: /linear route ..."); + return usage("Linear route", "Usage: /linear route ..."); } if (group === "sync") { diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 1e14a7586..ab22addec 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -18,6 +18,9 @@ _ ____ _____ $ ade auth status Check local ADE CLI readiness $ ade code Open ADE Work chat in the terminal $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade.app deeplink via the OS + $ ade link lane | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade runtime start | stop | status Manage the machine runtime daemon $ ade serve Run the ADE runtime daemon in foreground $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout @@ -27,14 +30,14 @@ _ ____ _____ $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade operations status | wait Poll operation/test/chat/run status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces - $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions $ ade terminal list | read | write | signal Control the active in-chat terminal + $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -45,7 +48,8 @@ _ ____ _____ $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input $ ade app-control launch | snapshot | click Inspect and drive Electron apps - $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work + $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach + Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions @@ -72,7 +76,6 @@ _ ____ _____ $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" - $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 @@ -92,9 +95,6 @@ _ ____ _____ $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' Multi-parameter service call: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - Single scalar parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text $ ade actions list --domain pr --text $ ade actions run --input-json '{"key":"value"}' @@ -118,6 +118,9 @@ _ ____ _____ $ ade auth status Check local ADE CLI readiness $ ade code Open ADE Work chat in the terminal $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade.app deeplink via the OS + $ ade link lane | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade runtime start | stop | status Manage the machine runtime daemon $ ade serve Run the ADE runtime daemon in foreground $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout @@ -127,14 +130,14 @@ _ ____ _____ $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade operations status | wait Poll operation/test/chat/run status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces - $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions $ ade terminal list | read | write | signal Control the active in-chat terminal + $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -145,7 +148,8 @@ _ ____ _____ $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input $ ade app-control launch | snapshot | click Inspect and drive Electron apps - $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work + $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach + Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions @@ -172,7 +176,6 @@ _ ____ _____ $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" - $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 @@ -192,9 +195,6 @@ _ ____ _____ $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' Multi-parameter service call: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - Single scalar parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text $ ade actions list --domain pr --text $ ade actions run --input-json '{"key":"value"}' @@ -218,6 +218,9 @@ _ ____ _____ $ ade auth status Check local ADE CLI readiness $ ade code Open ADE Work chat in the terminal $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade.app deeplink via the OS + $ ade link lane | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade runtime start | stop | status Manage the machine runtime daemon $ ade serve Run the ADE runtime daemon in foreground $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout @@ -227,14 +230,14 @@ _ ____ _____ $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade operations status | wait Poll operation/test/chat/run status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces - $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions $ ade terminal list | read | write | signal Control the active in-chat terminal + $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -245,7 +248,8 @@ _ ____ _____ $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input $ ade app-control launch | snapshot | click Inspect and drive Electron apps - $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work + $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach + Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions @@ -272,7 +276,6 @@ _ ____ _____ $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" - $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 @@ -292,9 +295,6 @@ _ ____ _____ $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' Multi-parameter service call: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - Single scalar parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text $ ade actions list --domain pr --text $ ade actions run --input-json '{"key":"value"}' @@ -323,6 +323,9 @@ _ ____ _____ $ ade lanes archive Archive a lane in ADE $ ade lanes unarchive Restore an archived lane $ ade lanes attach --path --name Attach an external worktree + $ ade lanes reparent --parent Move lane onto a new parent (runs git rebase) + $ ade lanes reparent --parent --stack-base-branch + Reparent and stack onto a specific branch (e.g. origin/main) $ ade lanes actions --text List callable lane service methods ## ade git --help @@ -341,6 +344,10 @@ _ ____ _____ $ ade git status --full --lane --text Show full lane status, diff, and conflict state $ ade git fetch --lane Fetch remote refs $ ade git pull --lane Pull with ADE's ff-only lane operation + $ ade git pull --lane --rebase Pull and replay local commits on upstream + $ ade git pull --lane --merge Pull and merge upstream into the lane + $ ade git undo --lane Reset to the previous recorded HEAD + $ ade git redo --lane Restore the last undone HEAD $ ade git sync --lane --rebase --base main Sync the lane with its base branch $ ade git stage --lane src/file.ts Stage one file @@ -351,7 +358,8 @@ _ ____ _____ $ ade git push --lane --force-with-lease Force-push through ADE with lease $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email - $ ade git stash push|list|apply|pop Use ADE lane stash actions + $ ade git stash push|list|apply|pop|drop Use ADE lane stash actions + pop/drop resolve the saved stash OID before changing it $ ade git rebase --lane --ai Rebase with ADE conflict support $ ade git rebase continue --lane Continue an in-progress rebase $ ade git conflict show --lane --text Inspect merge/rebase conflict state @@ -408,11 +416,13 @@ _ ____ _____ $ ade prs list --text List PRs known to ADE $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch - $ ade prs create --lane --base main Open and map a GitHub PR from a lane + $ ade prs create --lane --base main Open and map a GitHub PR; prints GitHub + ADE URLs $ ade prs create --lane --close-linear-issue-on-merge $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status $ ade prs comments --text Show unresolved review work + $ ade prs github-snapshot --include-external-closed + Include closed external PR history in the GitHub snapshot $ ade prs inventory Refresh ADE issue inventory $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade prs path-to-merge --model --conflict-strategy auto --force-finalize conditional @@ -549,6 +559,8 @@ _ ____ _____ $ ade linear sync queue --text List sync queue items $ ade linear sync resolve --queue-item --action approve $ ade linear route worker --input-json '{"issueId":"LIN-123","workerId":"worker-1"}' + $ ade linear install Register ADE as the "Open in coding tool" target + $ ade linear install --dry-run Preview the ~/.linear/coding-tools.json write ## ade automations --help _ ____ _____ @@ -607,9 +619,9 @@ _ ____ _____ Coordinator runtime tools - Coordinator tools expose orchestration operations used by mission agents. - List tool names with: - $ ade actions call list_ade_actions --input-json '{"domain":"orchestrator_core"}' + Coordinator tools expose orchestration operations used by agent runtimes. + List available tool names with: + $ ade actions list --text $ ade coordinator --input-json '{"key":"value"}' @@ -832,6 +844,9 @@ _ ____ _____ $ ade auth status Check local ADE CLI readiness $ ade code Open ADE Work chat in the terminal $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade.app deeplink via the OS + $ ade link lane | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade runtime start | stop | status Manage the machine runtime daemon $ ade serve Run the ADE runtime daemon in foreground $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout @@ -841,14 +856,14 @@ _ ____ _____ $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade operations status | wait Poll operation/test/chat/run status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces - $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions $ ade terminal list | read | write | signal Control the active in-chat terminal + $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -859,7 +874,8 @@ _ ____ _____ $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input $ ade app-control launch | snapshot | click Inspect and drive Electron apps - $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work + $ ade macos-vm status | start | restart | wipe | install-runtime | set-credentials | get-credentials | storage | display-session | detach + Run ADE's singleton Apple silicon macOS VM $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions @@ -886,7 +902,6 @@ _ ____ _____ $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" - $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 @@ -906,9 +921,6 @@ _ ____ _____ $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' Multi-parameter service call: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - Single scalar parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text $ ade actions list --domain pr --text $ ade actions run --input-json '{"key":"value"}' @@ -961,9 +973,6 @@ _ ____ _____ $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' argsList is for service methods with multiple positional parameters: $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' - scalar is for one non-object parameter: - $ ade actions run mission.get --scalar mission-1 - $ ade actions list --text Domain-grouped action catalog $ ade actions list --domain git --text Narrow the catalog $ ade actions run --input-json '{"key":"value"}' diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 8d504e9ef..9d23fcc7c 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -7,7 +7,7 @@ description: Use this skill when an agent needs to inspect or operate ADE itself ## Core rule -Use normal shell commands for local repo edits, tests, and Git inspection. Use `ade` when you need ADE state or ADE-owned services: lanes, chats, missions, PR metadata, proof/artifacts, managed terminals, App Control, iOS Simulator, browser, macOS VM, settings, usage, updates, or service actions. +Use normal shell commands for local repo edits, tests, and Git inspection. Use `ade` when you need ADE state or ADE-owned services: lanes, chats, PR metadata, proof/artifacts, managed terminals, App Control, iOS Simulator, browser, macOS VM, settings, usage, updates, or service actions. ## First checks diff --git a/apps/desktop/resources/agent-skills/ade-cto-missions/SKILL.md b/apps/desktop/resources/agent-skills/ade-cto-missions/SKILL.md deleted file mode 100644 index cc0649208..000000000 --- a/apps/desktop/resources/agent-skills/ade-cto-missions/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: ade-cto-missions -description: Use this skill when operating ADE CTO, missions, coordinator tools, worker agents, Linear routing, multi-agent orchestration, or mission run inspection. ---- - -# ADE CTO and missions - -## CTO - -Use CTO commands for team-lead state and Work chats: - -```bash -ade cto state --text -ade cto chats --text -ade help cto -``` - -## Missions - -Use missions for orchestrated multi-step work: - -```bash -ade missions list --text -ade missions launch --prompt "..." --manual --text -ade missions watch --text -ade missions graph --text -ade missions runs --text -``` - -## Coordinator and Linear - -```bash -ade coordinator --help -ade linear workflows --text -ade linear run --text -ade linear sync --text -``` - -## Operating rules - -- Keep worker briefs small and specific. -- Inspect source docs and code for non-obvious conventions and past pitfalls. -- When polling long-running mission/worker state, return compact summaries instead of pasting full logs. -- If a worker result conflicts with repo evidence, inspect the files yourself before merging its conclusion. diff --git a/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md b/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md index 79e9a1341..ce72c589e 100644 --- a/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md @@ -7,7 +7,7 @@ description: Use this skill when the user asks for proof, screenshots, video, ar ## Rule -When the user asks to capture, send, attach, or provide proof, create evidence with the relevant tool, then register it through ADE so it appears in the proof drawer for the active chat, mission, or lane. +When the user asks to capture, send, attach, or provide proof, create evidence with the relevant tool, then register it through ADE so it appears in the proof drawer for the active chat or lane. ## Commands @@ -30,4 +30,3 @@ ade help proof - Do not leave proof as an unregistered local file when the user expects ADE to show it. - Include enough context in the artifact name/description to understand what was verified. - Clean up stale processes you started before declaring proof complete. - diff --git a/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs b/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs index de66f40fb..2701c8323 100644 --- a/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs +++ b/apps/desktop/scripts/export-browser-mock-ade-snapshot.mjs @@ -680,7 +680,6 @@ function rowToQueueState(row) { permissionMode: "guarded_edit", confidenceThreshold: null, originSurface: "manual", - originMissionId: null, originRunId: null, originLabel: null, ...safeJson(row.config_json, {}), @@ -862,167 +861,6 @@ function buildGithubSnapshot({ }; } -function buildMissionSummaries(projectId) { - if (!hasTable("missions")) return []; - return allRows( - ` - select - m.*, - l.name as lane_name, - ml.name as mission_lane_name, - rl.name as result_lane_name, - (select count(*) from mission_artifacts ma where ma.mission_id = m.id) as artifact_count, - (select count(*) from mission_interventions mi where mi.mission_id = m.id and mi.status = 'open') as open_interventions, - (select count(*) from mission_steps ms where ms.mission_id = m.id) as total_steps, - (select count(*) from mission_steps ms where ms.mission_id = m.id and ms.status = 'completed') as completed_steps - from missions m - left join lanes l on l.id = m.lane_id - left join lanes ml on ml.id = m.mission_lane_id - left join lanes rl on rl.id = m.result_lane_id - where m.project_id = ? - and m.archived_at is null - order by m.updated_at desc, m.created_at desc - limit 300 - `, - [projectId], - ).map((row) => ({ - id: row.id, - title: row.title, - prompt: row.prompt, - laneId: row.lane_id, - laneName: row.lane_name, - missionLaneId: row.mission_lane_id ?? null, - missionLaneName: row.mission_lane_name ?? null, - resultLaneId: row.result_lane_id ?? null, - resultLaneName: row.result_lane_name ?? null, - status: row.status, - priority: row.priority, - executionMode: row.execution_mode, - targetMachineId: row.target_machine_id, - outcomeSummary: row.outcome_summary, - lastError: row.last_error, - artifactCount: Number(row.artifact_count ?? 0), - openInterventions: Number(row.open_interventions ?? 0), - totalSteps: Number(row.total_steps ?? 0), - completedSteps: Number(row.completed_steps ?? 0), - createdAt: row.created_at, - updatedAt: row.updated_at, - startedAt: row.started_at, - completedAt: row.completed_at, - })); -} - -function buildMissionDashboard(missions) { - const activeStatuses = new Set(["queued", "planning", "plan_review", "in_progress", "intervention_required"]); - const completed = missions.filter((mission) => mission.status === "completed").length; - const failed = missions.filter((mission) => mission.status === "failed").length; - const finished = completed + failed; - return { - active: missions.filter((mission) => activeStatuses.has(mission.status)).slice(0, 50), - recent: missions.slice(0, 12), - weekly: { - missions: missions.length, - successRate: finished > 0 ? completed / finished : 0, - avgDurationMs: 0, - totalCostUsd: 0, - }, - }; -} - -function buildMissionFullViews({ projectId, missions }) { - if (!missions.length) return {}; - const steps = maybeAll( - "mission_steps", - "select * from mission_steps where project_id = ? order by mission_id asc, step_index asc", - [projectId], - ); - const artifacts = maybeAll( - "mission_artifacts", - "select * from mission_artifacts where project_id = ? order by created_at desc", - [projectId], - ); - const interventions = maybeAll( - "mission_interventions", - "select * from mission_interventions where project_id = ? order by created_at desc", - [projectId], - ); - const stepsByMission = Map.groupBy - ? Map.groupBy(steps, (row) => row.mission_id) - : groupBy(steps, (row) => row.mission_id); - const artifactsByMission = Map.groupBy - ? Map.groupBy(artifacts, (row) => row.mission_id) - : groupBy(artifacts, (row) => row.mission_id); - const interventionsByMission = Map.groupBy - ? Map.groupBy(interventions, (row) => row.mission_id) - : groupBy(interventions, (row) => row.mission_id); - return Object.fromEntries( - missions.map((mission) => [ - mission.id, - { - mission, - steps: (stepsByMission.get(mission.id) ?? []).map((row) => ({ - id: row.id, - missionId: row.mission_id, - index: Number(row.step_index ?? 0), - title: row.title, - detail: row.detail, - kind: row.kind, - laneId: row.lane_id, - status: row.status, - createdAt: row.created_at, - updatedAt: row.updated_at, - startedAt: row.started_at, - completedAt: row.completed_at, - metadata: safeJson(row.metadata_json, {}), - })), - artifacts: (artifactsByMission.get(mission.id) ?? []).map((row) => ({ - id: row.id, - missionId: row.mission_id, - artifactType: row.artifact_type, - title: row.title, - description: row.description, - uri: row.uri, - laneId: row.lane_id, - metadata: safeJson(row.metadata_json, {}), - createdAt: row.created_at, - updatedAt: row.updated_at, - createdBy: row.created_by, - })), - interventions: (interventionsByMission.get(mission.id) ?? []).map((row) => ({ - id: row.id, - missionId: row.mission_id, - interventionType: row.intervention_type, - status: row.status, - resolutionKind: row.resolution_kind, - title: row.title, - body: row.body, - requestedAction: row.requested_action, - resolutionNote: row.resolution_note, - laneId: row.lane_id, - metadata: safeJson(row.metadata_json, {}), - createdAt: row.created_at, - updatedAt: row.updated_at, - resolvedAt: row.resolved_at, - })), - runGraph: null, - checkpoints: [], - dashboard: null, - }, - ]), - ); -} - -function groupBy(items, keyFn) { - const grouped = new Map(); - for (const item of items) { - const key = keyFn(item); - const bucket = grouped.get(key) ?? []; - bucket.push(item); - grouped.set(key, bucket); - } - return grouped; -} - function buildOperations(projectId) { return maybeAll( "operations", @@ -1192,7 +1030,6 @@ function buildAutomations(projectId) { id: row.id, automationId: row.automation_id, chatSessionId: row.chat_session_id, - missionId: row.mission_id, triggerType: row.trigger_type, startedAt: row.started_at, endedAt: row.ended_at, @@ -1276,7 +1113,7 @@ const hasLaneSnapshots = hasTable("lane_state_snapshots"); const laneRows = allRows( `select id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, - is_edit_protected, parent_lane_id, color, icon, tags_json, folder, mission_id, lane_role, + is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at from lanes where project_id = ? @@ -1315,8 +1152,6 @@ const lanes = laneRows.map((row) => { icon: row.icon, tags: safeJson(row.tags_json, []), folder: row.folder, - missionId: row.mission_id, - laneRole: row.lane_role, status: { dirty: Boolean(snap?.dirty), ahead: Number(snap?.ahead ?? 0), @@ -1374,7 +1209,6 @@ const githubSnapshot = buildGithubSnapshot({ integrationWorkflows, prSnapshots, }); -const missions = buildMissionSummaries(projectId); const operations = buildOperations(projectId); const sessions = buildSessions(projectId); let processDefinitions = buildProcessDefinitions(projectId); @@ -1396,8 +1230,6 @@ const processGroups = finalizedRun.processGroups; processRuntime = ensureDemoProcessRuntime(processRuntime, lanes, processDefinitions); const automations = buildAutomations(projectId); -const missionDashboard = buildMissionDashboard(missions); -const missionFullViews = buildMissionFullViews({ projectId, missions }); const usageSnapshot = buildUsageSnapshot(); const ctoState = getCtoState(projectId); @@ -1436,9 +1268,6 @@ const snapshot = { integrationWorkflows, rebaseNeeds, githubSnapshot, - missions, - missionDashboard, - missionFullViews, operations, sessions, chatTranscripts, @@ -1457,7 +1286,7 @@ const snapshot = { await fs.writeFile(OUT_FILE, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); console.log( `[export-browser-mock-ade] Wrote browser snapshot for ${projectRow.displayName} → ${OUT_FILE}\n` + - ` lanes=${lanes.length} prs=${prs.length} prSnapshots=${prSnapshots.length} operations=${operations.length} sessions=${sessions.length} chatTranscripts=${Object.keys(chatTranscripts).length} processes=${processDefinitions.length}/${processRuntime.length} stacks=${stackButtons.length} groups=${processGroups.length} missions=${missions.length}\n` + + ` lanes=${lanes.length} prs=${prs.length} prSnapshots=${prSnapshots.length} operations=${operations.length} sessions=${sessions.length} chatTranscripts=${Object.keys(chatTranscripts).length} processes=${processDefinitions.length}/${processRuntime.length} stacks=${stackButtons.length} groups=${processGroups.length}\n` + ` filesWorkspaces=${lanes.length} filesTreeWorkspaces=${filesTreeWorkspaceCount} filesTreeDirs=${filesTreeDirKeys} filesWithEmbeddedText=${filesContentEntryCount}\n` + "Restart Vite or refresh the browser to pick up the updated data.", ); diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index 4612ea93b..ad7c018c8 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -22,7 +22,6 @@ const bundledAgentSkills = [ "ade-browser", "ade-pr-workflows", "ade-lanes-git", - "ade-cto-missions", "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 9ac5a5f13..c0cac7438 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -24,7 +24,6 @@ const bundledAgentSkills = [ "ade-browser", "ade-pr-workflows", "ade-lanes-git", - "ade-cto-missions", "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a57482c45..a5a75007f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -131,8 +131,6 @@ import { } from "./services/runtime/machineStateMigration"; import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestionService"; import { createAutoRebaseService } from "./services/lanes/autoRebaseService"; -import { createMissionService } from "./services/missions/missionService"; -import { createMissionPreflightService } from "./services/missions/missionPreflightService"; import { createCtoStateService } from "./services/cto/ctoStateService"; import { createWorkerAgentService } from "./services/cto/workerAgentService"; import { createWorkerRevisionService } from "./services/cto/workerRevisionService"; @@ -155,10 +153,6 @@ import { createLinearDispatcherService } from "./services/cto/linearDispatcherSe import { publishLinearLaneCard } from "./services/cto/linearLaneCardService"; import { createLinearIngressService } from "./services/cto/linearIngressService"; import { createLinearSyncService } from "./services/cto/linearSyncService"; -import { createOrchestratorService } from "./services/orchestrator/orchestratorService"; -import { createAiOrchestratorService } from "./services/orchestrator/aiOrchestratorService"; -import { createMissionBudgetService } from "./services/orchestrator/missionBudgetService"; -import { transitionMissionStatus } from "./services/orchestrator/missionLifecycle"; import { createComputerUseArtifactBrokerService } from "./services/computerUse/computerUseArtifactBrokerService"; import { createIosSimulatorService } from "./services/ios/iosSimulatorService"; import { createAppControlService } from "./services/appControl/appControlService"; @@ -330,8 +324,6 @@ const enableAllBackgroundTasks = const defaultEnabledBackgroundTaskFlags = new Set([ "ADE_ENABLE_CONFIG_RELOAD", "ADE_ENABLE_USAGE_TRACKING", - "ADE_ENABLE_MISSION_QUEUE", - "ADE_ENABLE_TEAM_RUNTIME_RECOVERY", "ADE_ENABLE_HEAD_WATCHER", "ADE_ENABLE_PORT_ALLOCATION_RECOVERY", "ADE_ENABLE_SYNC_INIT", @@ -1207,7 +1199,7 @@ app.whenReady().then(async () => { localRuntimePool.noteServiceInstallSkipped("Background service installation is skipped in tests."); } // Soft cap for project contexts that have NO user work at all (no chats, - // no live PTYs, no active sessions/missions/tests). Anything with work is + // no live PTYs, no active sessions/tests). Anything with work is // protected by `hasActiveProjectWorkloads` and is never evicted regardless // of this number. The cap exists only as a safety valve against opening // hundreds of empty projects in a long-running session — well above any @@ -1564,14 +1556,6 @@ app.whenReady().then(async () => { return keepAliveOnProbeFailure("pty_sessions", error); } - try { - if (ctx.missionService?.list({ status: "active", limit: 1 }).length > 0) { - return true; - } - } catch (error) { - return keepAliveOnProbeFailure("missions", error); - } - try { if (ctx.testService?.hasActiveRuns()) { return true; @@ -1879,9 +1863,6 @@ app.whenReady().then(async () => { let testServiceRef: ReturnType | null = null; let gitServiceRef: ReturnType | null = null; - let missionBudgetServiceRef: ReturnType< - typeof createMissionBudgetService - > | null = null; let linearIssueTrackerRef: LinearIssueTracker | null = null; const lastHeadByLaneId = new Map(); @@ -2436,12 +2417,6 @@ app.whenReady().then(async () => { }); prPollingServiceRef = prPollingService; - let orchestratorServiceRef: ReturnType< - typeof createOrchestratorService - > | null = null; - let aiOrchestratorServiceRef: ReturnType< - typeof createAiOrchestratorService - > | null = null; let linearDispatcherServiceRef: ReturnType< typeof createLinearDispatcherService > | null = null; @@ -2544,15 +2519,6 @@ app.whenReady().then(async () => { }); } void linearSyncServiceRef?.processActiveRunsNow().catch(() => {}); - if (orchestratorServiceRef) { - void orchestratorServiceRef - .onTrackedSessionEnded({ - laneId, - sessionId, - exitCode, - }) - .catch(() => {}); - } }; let syncServiceRef: ReturnType | null = null; @@ -2579,7 +2545,6 @@ app.whenReady().then(async () => { }, onSessionEnded: onTrackedSessionEnded, onSessionRuntimeSignal: (signal) => { - aiOrchestratorServiceRef?.onSessionRuntimeSignal(signal); emitProjectEvent(projectRoot, IPC.sessionsChanged, { sessionId: signal.sessionId, reason: "meta-updated", @@ -2756,8 +2721,6 @@ app.whenReady().then(async () => { workerHeartbeatService, linearIssueTracker, flowPolicyService, - getMissionService: () => missionServiceRef, - getAiOrchestratorService: () => aiOrchestratorServiceRef, getLinearDispatcherService: () => linearDispatcherServiceRef, linearClient, linearCredentials: linearCredentialService, @@ -2770,7 +2733,6 @@ app.whenReady().then(async () => { getGitService: () => gitServiceRef, conflictService, getWorkerBudgetService: () => workerBudgetService, - getMissionBudgetService: () => missionBudgetServiceRef, laneService, sessionService, processRegistry, @@ -2781,7 +2743,6 @@ app.whenReady().then(async () => { appVersion: app.getVersion(), getAdeCliAgentEnv: adeCliService.agentEnv, onEvent: (event) => { - aiOrchestratorServiceRef?.onAgentChatEvent(event); emitProjectEvent(projectRoot, IPC.agentChatEvent, event); }, onSessionEnded: onTrackedSessionEnded, @@ -2921,74 +2882,6 @@ app.whenReady().then(async () => { automationService, }); - let missionServiceRef: ReturnType | null = - null; - const missionService = createMissionService({ - db, - projectId, - projectRoot, - logger, - onBlockingInterventionAdded: ({ missionId, intervention }) => { - const currentMissionService = missionServiceRef; - const currentOrchestratorService = orchestratorServiceRef; - if (!currentMissionService || !currentOrchestratorService) return; - transitionMissionStatus( - { - logger, - missionService: currentMissionService, - orchestratorService: currentOrchestratorService, - } as any, - missionId, - "intervention_required", - { - lastError: - intervention.body?.trim() || intervention.title?.trim() || null, - }, - ); - }, - onEvent: (event) => { - emitProjectEvent(projectRoot, IPC.missionsEvent, event); - if (event.missionId) { - automationService?.onMissionUpdated({ missionId: event.missionId }); - } - if (event.reason === "ready_to_start" && event.missionId) { - void aiOrchestratorServiceRef - ?.startMissionRun({ - missionId: event.missionId, - queueClaimToken: event.claimToken ?? null, - }) - .catch((error) => { - logger.warn("missions.queue_autostart_failed", { - missionId: event.missionId, - error: error instanceof Error ? error.message : String(error), - }); - }); - } - }, - }); - missionServiceRef = missionService; - // Run phase built-in migration/cleanup once at startup so launcher state is canonical. - try { - missionService.listPhaseProfiles({ includeArchived: true }); - missionService.listPhaseItems({ includeArchived: true }); - } catch (error) { - logger.warn("missions.phase_storage_seed_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - const missionBudgetService = createMissionBudgetService({ - db, - logger, - projectId, - projectRoot, - missionService, - aiIntegrationService, - projectConfigService, - }); - missionBudgetServiceRef = missionBudgetService; - let missionPreflightService: ReturnType< - typeof createMissionPreflightService - >; const deferredProjectStartCancels = new Set<() => void>(); const scheduleDeferredProjectStart = ( task: () => Promise | unknown, @@ -3085,29 +2978,11 @@ app.whenReady().then(async () => { "ADE_ENABLE_PORT_ALLOCATION_RECOVERY", ); - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - conflictService, - ptyService, - agentChatService, - prService, - projectConfigService, - aiIntegrationService, - onEvent: (event) => { - aiOrchestratorServiceRef?.onOrchestratorRuntimeEvent(event); - emitProjectEvent(projectRoot, IPC.orchestratorEvent, event); - }, - }); - orchestratorServiceRef = orchestratorService; const computerUseArtifactBrokerService = createComputerUseArtifactBrokerService({ db, projectId, projectRoot, - missionService, - orchestratorService, logger, onEvent: (payload) => emitProjectEvent(projectRoot, IPC.computerUseEvent, payload), @@ -3312,37 +3187,6 @@ app.whenReady().then(async () => { getCredentials: macosVmSvcAny.getCredentials.bind(macosVmService), }); } - missionPreflightService = createMissionPreflightService({ - logger, - projectRoot, - missionService, - laneService, - aiIntegrationService, - projectConfigService, - missionBudgetService, - computerUseArtifactBrokerService, - }); - const aiOrchestratorService = createAiOrchestratorService({ - db, - logger, - missionService, - orchestratorService, - agentChatService, - laneService, - projectConfigService, - aiIntegrationService, - prService, - conflictService, - queueLandingService, - projectRoot, - missionBudgetService, - computerUseArtifactBrokerService, - onThreadEvent: (event) => - emitProjectEvent(projectRoot, IPC.orchestratorThreadEvent, event), - onDagMutation: (event) => - emitProjectEvent(projectRoot, IPC.orchestratorDagMutation, event), - }); - aiOrchestratorServiceRef = aiOrchestratorService; // Phone sync is owned by the per-machine ADE service. The desktop // keeps a non-host sync service for legacy viewer state and explicit // diagnostics only; ADE_ENABLE_DESKTOP_SYNC_HOST=1 re-enables the old @@ -3377,7 +3221,6 @@ app.whenReady().then(async () => { rebaseSuggestionService, autoRebaseService, computerUseArtifactBrokerService, - missionService, agentChatService, workerAgentService, workerBudgetService, @@ -3446,20 +3289,6 @@ app.whenReady().then(async () => { 0, "ADE_ENABLE_SYNC_INIT", ); - scheduleBackgroundProjectTask( - "missions.process_queue", - () => { - missionService.processQueue(); - }, - (error) => { - logger.warn("missions.queue_autostart_bootstrap_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }, - 20_000, - "ADE_ENABLE_MISSION_QUEUE", - ); - logger.info("project.init_stage", { projectRoot, stage: "linear_closeout_init", @@ -3467,8 +3296,6 @@ app.whenReady().then(async () => { const linearCloseoutService = createLinearCloseoutService({ issueTracker: linearIssueTracker, outboundService: linearOutboundService, - missionService, - orchestratorService, prService, computerUseArtifactBrokerService, logger, @@ -3483,8 +3310,6 @@ app.whenReady().then(async () => { issueTracker: linearIssueTracker, workerAgentService, workerHeartbeatService, - missionService, - aiOrchestratorService, agentChatService, laneService, templateService: linearTemplateService, @@ -3601,19 +3426,6 @@ app.whenReady().then(async () => { "ADE_ENABLE_LINEAR_INGRESS", ); - // Resume any active team runtimes that were running before app restart - scheduleBackgroundProjectTask( - "orchestrator.resume_team_runtimes", - () => aiOrchestratorService.resumeActiveTeamRuntimes(), - (error) => { - logger.warn("orchestrator.resume_team_runtimes_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }, - 60_000, - "ADE_ENABLE_TEAM_RUNTIME_RECOVERY", - ); - const automationPlannerService = createAutomationPlannerService({ logger, projectRoot, @@ -3650,12 +3462,6 @@ app.whenReady().then(async () => { projectConfigService, usageTrackingService, }); - automationService?.bindMissionRuntime({ - missionService, - aiOrchestratorService, - budgetCapService, - workerHeartbeatService, - }); scheduleBackgroundProjectTask( "automations.ingress_start", () => automationIngressService.start(), @@ -3886,8 +3692,6 @@ app.whenReady().then(async () => { conflictService, gitService, diffService, - missionService, - missionPreflightService, ptyService, testService, aiIntegrationService, @@ -3918,9 +3722,6 @@ app.whenReady().then(async () => { appControlService, builtInBrowserService, macosVmService, - orchestratorService, - aiOrchestratorService, - missionBudgetService, syncHostService: syncService.getHostService(), syncService, automationIngressService, @@ -4068,8 +3869,7 @@ app.whenReady().then(async () => { // Wire the automation runtime into the shared ADE-action registry so // that `ade-action` automation steps can invoke the same domain services // the RPC server exposes. We do this lazily — the registry re-resolves - // services on every call so that runtime bindings (bindMissionRuntime, - // ctoStateService) that settle later are still visible. + // services on every call so late-bound runtime state remains visible. { const adeActionLookup: AutomationAdeActionRegistry = { isAllowed(domain: string, action: string): boolean { @@ -4104,9 +3904,6 @@ app.whenReady().then(async () => { prService, testService, agentChatService, - missionService, - aiOrchestratorService, - orchestratorService, ctoStateService, workerAgentService, sessionService, @@ -4198,11 +3995,6 @@ app.whenReady().then(async () => { apnsService, apnsKeyStore, notificationEventBus, - missionService, - missionPreflightService, - orchestratorService, - missionBudgetService, - aiOrchestratorService, agentChatService, projectConfigService, processService, @@ -4388,11 +4180,6 @@ app.whenReady().then(async () => { apnsService: null, apnsKeyStore: null, notificationEventBus: null, - missionService: null, - missionPreflightService: null, - orchestratorService: null, - missionBudgetService: null, - aiOrchestratorService: null, projectConfigService: null, processService: null, sessionDeltaService: null, @@ -4490,11 +4277,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - ctx.aiOrchestratorService.dispose(); - } catch { - // ignore - } try { ctx.linearIngressService?.dispose(); } catch { @@ -5150,11 +4932,6 @@ app.whenReady().then(async () => { contexts.add(getActiveContext()); for (const ctx of contexts) { - try { - ctx.aiOrchestratorService?.dispose?.(); - } catch { - // ignore - } try { ctx.automationService?.dispose?.(); } catch { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 23cc61fbb..49a20b230 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -447,8 +447,6 @@ function makeLane(overrides: Partial & Pick { const stateSnapshot = { laneId: lane.id, agentSummary: { activeAgent: "codex" }, - missionSummary: { missionId: "mission-1" }, updatedAt: TEST_NOW, }; const sessions = [ @@ -797,12 +794,6 @@ describe("runtime AI actions", () => { dailyUsage: 2, dailyLimit: 5, }); - expect(status.features).toContainEqual({ - feature: "mission_planning", - enabled: false, - dailyUsage: 0, - dailyLimit: null, - }); }); it("delegates AI key mutations to the runtime service", () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 7abf3922a..9509db9f6 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -26,11 +26,6 @@ import type { } from "../../../shared/types/chat"; import type { AutomationRule } from "../../../shared/types/config"; import { buildPrAiResolutionContextKey } from "../../../shared/types"; -import type { - OrchestratorChatMessage, - OrchestratorRun, - OrchestratorRunGraph, -} from "../../../shared/types/orchestrator"; import type { AiConfig, ApplyLaneTemplateArgs, @@ -80,7 +75,6 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../ import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; import { mapPermissionModeForModelFamily } from "../prs/resolverUtils"; import { getErrorMessage, isRecord, nowIso } from "../shared/utils"; -import { readCoordinatorCheckpoint } from "../orchestrator/missionStateDoc"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import { buildResumeArgv, @@ -101,10 +95,6 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "ai", "onboarding", "automation_planner", - "mission", - "orchestrator", - "orchestrator_core", - "mission_budget", "cto_state", "worker_agent", "session", @@ -514,101 +504,6 @@ export const ADE_ACTION_ALLOWLIST: Partial !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) - .slice(0, RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT) - .map((entry) => { - if (!isRecord(entry)) return entry; - return { - path: typeof entry.path === "string" ? entry.path : "", - bytes: typeof entry.bytes === "number" ? entry.bytes : 0, - sha256: typeof entry.sha256 === "string" ? entry.sha256 : "", - truncated: entry.truncated === true, - mode: typeof entry.mode === "string" ? entry.mode : undefined, - }; - }); - return { - ...value, - docs, - docsOmittedCount: Math.max(0, rawDocs.length - docs.length), - }; -} - -function compactDocRefsArrayForTransport(rawDocs: unknown[], limit: number): unknown[] { - return rawDocs - .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) - .slice(0, limit); -} - -function compactPayloadForTransport(payload: Record | null): Record | null { - if (!payload) return payload; - const next: Record = { ...payload }; - if (Array.isArray(next.docsRefs)) { - const rawDocsRefs = next.docsRefs; - const docsRefs = compactDocRefsArrayForTransport(rawDocsRefs, PAYLOAD_DOC_REF_TRANSPORT_LIMIT); - next.docsRefs = docsRefs; - next.docsRefsOmittedCount = Math.max(0, rawDocsRefs.length - docsRefs.length); - } - return next; -} - -function compactChatToolValueForTransport(value: unknown): unknown { - if (value == null || typeof value === "boolean" || typeof value === "number") return value; - if (typeof value === "string") { - if (value.length <= CHAT_TOOL_RESULT_STRING_LIMIT) return value; - return { - preview: value.slice(0, CHAT_TOOL_RESULT_STRING_LIMIT), - omittedChars: value.length - CHAT_TOOL_RESULT_STRING_LIMIT, - }; - } - if (Array.isArray(value)) { - return { - type: "array", - length: value.length, - preview: value - .slice(0, CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT) - .map((entry) => compactChatToolValueForTransport(entry)), - omittedItems: Math.max(0, value.length - CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT), - }; - } - if (!isRecord(value)) return value; - - const safeKeys = [ - "ok", - "status", - "outcome", - "summary", - "message", - "error", - "workerId", - "stepId", - "stepKey", - "runId", - "missionId", - "filesChanged", - "testsRun", - "artifacts", - ]; - const next: Record = {}; - for (const key of safeKeys) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - next[key] = compactChatToolValueForTransport(value[key]); - } - } - const keys = Object.keys(value); - next.__adeTransportCompact = true; - next.keys = keys.slice(0, CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); - next.omittedKeys = Math.max(0, keys.length - CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); - return next; -} - -function compactChatMessageMetadataForTransport(metadata: OrchestratorChatMessage["metadata"]): OrchestratorChatMessage["metadata"] { - if (!isRecord(metadata)) return metadata; - const structuredStream = isRecord(metadata.structuredStream) ? metadata.structuredStream : null; - if (!structuredStream) return metadata; - const nextStructured = { ...structuredStream }; - if (Object.prototype.hasOwnProperty.call(nextStructured, "result")) { - nextStructured.result = compactChatToolValueForTransport(nextStructured.result); - } - return { - ...metadata, - structuredStream: nextStructured, - }; -} - -function compactChatMessageForTransport(message: OrchestratorChatMessage): OrchestratorChatMessage { - return { - ...message, - metadata: compactChatMessageMetadataForTransport(message.metadata), - }; -} - -function compactRunMetadataForTransport(metadata: OrchestratorRun["metadata"]): OrchestratorRun["metadata"] { - if (!isRecord(metadata)) return metadata; - const next: Record = { ...metadata }; - if (isRecord(next.runtimeCursor)) { - next.runtimeCursor = compactRuntimeCursorForTransport(next.runtimeCursor); - } - return next; -} - -function compactRunForTransport(run: OrchestratorRun): OrchestratorRun { - return { - ...run, - metadata: compactRunMetadataForTransport(run.metadata), - }; -} - -function compactRunGraphForTransport(graph: OrchestratorRunGraph): OrchestratorRunGraph { - return { - ...graph, - run: compactRunForTransport(graph.run), - contextSnapshots: graph.contextSnapshots - .slice(0, RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT) - .map((snapshot) => ({ - ...snapshot, - cursor: compactRuntimeCursorForTransport(snapshot.cursor) as typeof snapshot.cursor, - })), - handoffs: graph.handoffs.map((handoff) => ({ - ...handoff, - payload: compactPayloadForTransport(handoff.payload) ?? {}, - })), - timeline: graph.timeline.map((event) => ({ - ...event, - detail: compactPayloadForTransport(event.detail), - })), - runtimeEvents: graph.runtimeEvents?.map((event) => ({ - ...event, - payload: compactPayloadForTransport(event.payload), - })), - }; -} - -function buildMissionDomainService(runtime: AdeRuntime): OpaqueService | null { - const service = runtime.missionService; - if (!service) return null; - return { - ...(service as OpaqueService), - async getFullMissionView(args?: unknown): Promise> { - const request = asActionRecord(args); - const missionId = typeof request.missionId === "string" ? request.missionId.trim() : ""; - if (!missionId) { - return { mission: null, runGraph: null, artifacts: [], checkpoints: [], dashboard: null }; - } - - let dashboard: unknown = null; - try { - dashboard = service.getDashboard(); - } catch { - // Dashboard is supplemental for this composed view. - } - - const mission = await service.get(missionId); - let runGraph: unknown = null; - let artifacts: unknown[] = []; - let checkpoints: unknown[] = []; - - const orchestratorService = requireService(runtime.orchestratorService, "Orchestrator service not available."); - const aiOrchestratorService = requireService(runtime.aiOrchestratorService, "AI orchestrator service not available."); - const runs = await orchestratorService.listRuns({ missionId, limit: 20 }); - const activeStatuses = new Set(["active", "bootstrapping", "queued", "paused"]); - const preferredRun = runs.find((entry) => activeStatuses.has(entry.status)) ?? runs[0]; - if (preferredRun) { - const [graph, arts, cps] = await Promise.all([ - Promise.resolve(orchestratorService.getRunGraph({ runId: preferredRun.id, timelineLimit: 120 })), - Promise.resolve(aiOrchestratorService.listArtifacts({ missionId, runId: preferredRun.id })).catch(() => []), - Promise.resolve(aiOrchestratorService.listWorkerCheckpoints({ missionId, runId: preferredRun.id })).catch(() => []), - ]); - runGraph = compactRunGraphForTransport(graph); - artifacts = Array.isArray(arts) ? arts : []; - checkpoints = Array.isArray(cps) ? cps : []; - } - - return { mission, runGraph, artifacts, checkpoints, dashboard }; - }, - preflight(args?: unknown): Promise { - const missionPreflightService = requireService(runtime.missionPreflightService, "Mission preflight service not available."); - return missionPreflightService.runPreflight(asActionRecord(args) as never); - }, - getRunView(args?: unknown): Promise { - const aiOrchestratorService = requireService(runtime.aiOrchestratorService, "AI orchestrator service not available."); - return aiOrchestratorService.getRunView(asActionRecord(args) as never); - }, - }; -} - -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) { - 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)); - } -} - -function buildOrchestratorCoreDomainService(runtime: AdeRuntime): OpaqueService | null { - const service = runtime.orchestratorService; - if (!service) return null; - return { - ...(service as OpaqueService), - listRuns: (args?: Parameters[0]) => - service.listRuns(args).map(compactRunForTransport), - getRunGraph: (args: Parameters[0]) => - compactRunGraphForTransport(service.getRunGraph(args)), - startRun: (args: Parameters[0]) => { - const started = service.startRun(args); - return { ...started, run: compactRunForTransport(started.run) }; - }, - tick: (args: Parameters[0]) => - compactRunForTransport(service.tick(args)), - pauseRun: (args: Parameters[0]) => - compactRunForTransport(service.pauseRun(args)), - resumeRun: (args: Parameters[0]) => - compactRunForTransport(service.resumeRun(args)), - finalizeRun: async (args: Parameters[0]) => { - const result = await (runtime.aiOrchestratorService?.finalizeRun - ? runtime.aiOrchestratorService.finalizeRun(args as never) - : service.finalizeRun(args)); - await waitForMissionCloseoutAfterFinalize(runtime, args.runId, result); - return result; - }, - }; -} - -function buildAiOrchestratorDomainService(runtime: AdeRuntime): OpaqueService | null { - const service = runtime.aiOrchestratorService; - if (!service) return null; - return { - ...(service as OpaqueService), - sendChat: async (args: Parameters[0]) => - compactChatMessageForTransport(await service.sendChat(args)), - getChat: (args: Parameters[0]) => - service.getChat(args).map(compactChatMessageForTransport), - getThreadMessages: (args: Parameters[0]) => - service.getThreadMessages(args).map(compactChatMessageForTransport), - sendThreadMessage: async (args: Parameters[0]) => - compactChatMessageForTransport(await service.sendThreadMessage(args)), - finalizeRun: async (args: Parameters[0]) => { - const result = await 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]) => - compactRunForTransport(await service.resumeRun(args)), - startMissionRun: async (args: Parameters[0]) => { - const result = await service.startMissionRun(args); - return result?.started - ? { ...result, started: { ...result.started, run: compactRunForTransport(result.started.run) } } - : result; - }, - getGlobalChat: (args: Parameters[0]) => - service.getGlobalChat(args).map(compactChatMessageForTransport), - sendAgentMessage: async (args: Parameters[0]) => - compactChatMessageForTransport(await service.sendAgentMessage(args)), - async getCheckpointStatus(args?: unknown): Promise | null> { - const runId = typeof asActionRecord(args).runId === "string" - ? String(asActionRecord(args).runId).trim() - : ""; - if (!runId) return null; - const checkpoint = await readCoordinatorCheckpoint(runtime.projectRoot, runId); - if (!checkpoint) return null; - return { - savedAt: checkpoint.savedAt, - turnCount: checkpoint.turnCount, - compactionCount: checkpoint.compactionCount, - }; - }, - }; -} - function mergeLaneDockerConfig( current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, @@ -2008,7 +1571,6 @@ const AI_SETTINGS_FEATURE_KEYS: AiFeatureKey[] = [ "commit_messages", "pr_descriptions", "terminal_summaries", - "mission_planning", "orchestrator", "initial_context", ]; @@ -3111,10 +2673,6 @@ export function getAdeActionDomainServices( ai: toService(buildAiDomainService(runtime)), onboarding: toService(runtime.onboardingService), automation_planner: toService(runtime.automationPlannerService), - mission: toService(buildMissionDomainService(runtime)), - orchestrator: toService(buildAiOrchestratorDomainService(runtime)), - orchestrator_core: toService(buildOrchestratorCoreDomainService(runtime)), - mission_budget: toService(runtime.missionBudgetService), cto_state: toService(buildCtoStateDomainService(runtime)), worker_agent: toService(buildWorkerAgentDomainService(runtime)), session: toService(buildSessionDomainService(runtime)), diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index aa1a5454f..fe780cf69 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -288,13 +288,13 @@ beforeEach(() => { describe("aiIntegrationService", () => { it("routes executeTask through the provider task runner", async () => { const { service, runCalls } = makeService({ - aiConfig: { features: { mission_planning: true } }, + aiConfig: { features: { initial_context: true } }, }); const result = await service.executeTask({ - feature: "mission_planning", + feature: "initial_context", taskType: "planning", - prompt: "Plan this mission", + prompt: "Plan this task", cwd: "/tmp", model: "anthropic/claude-sonnet-4-6" }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 5e8ded0f5..f30eecbeb 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -91,7 +91,6 @@ export type AiTaskType = | "handoff_summary" | "continuity_summary" | "context_compaction" - | "mission_planning" | "initial_context"; export type AiFeatureKey = @@ -100,7 +99,6 @@ export type AiFeatureKey = | "commit_messages" | "pr_descriptions" | "terminal_summaries" - | "mission_planning" | "orchestrator" | "initial_context"; @@ -197,7 +195,6 @@ const DEFAULT_AI_FEATURE_FLAGS: Record = { commit_messages: false, pr_descriptions: true, terminal_summaries: true, - mission_planning: true, orchestrator: true, initial_context: true, }; @@ -258,10 +255,6 @@ const TASK_DEFAULTS: Record = { modelId: "anthropic/claude-haiku-4-5", timeoutMs: 120_000 }, - mission_planning: { - modelId: DEFAULT_CLAUDE_TASK_MODEL_ID, - timeoutMs: 300_000 - }, initial_context: { modelId: DEFAULT_CLAUDE_TASK_MODEL_ID, timeoutMs: 120_000 diff --git a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts index f3a65d7f6..fb024940a 100644 --- a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts +++ b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts @@ -9,7 +9,6 @@ export const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ "commit_messages", "pr_descriptions", "terminal_summaries", - "mission_planning", "orchestrator", "initial_context", ]; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 9efa94aec..13e149ba1 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -48,8 +48,6 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo list: vi.fn().mockResolvedValue([]), create: vi.fn(), } as any, - missionService: null, - aiOrchestratorService: null, workerAgentService: null, workerHeartbeatService: null, linearDispatcherService: null, @@ -114,18 +112,6 @@ describe("createCtoOperatorTools", () => { expect(toolKeys).toContain("cancelLinearRun"); expect(toolKeys).toContain("rerouteLinearRun"); - // Mission tools - expect(toolKeys).toContain("listMissions"); - expect(toolKeys).toContain("startMission"); - expect(toolKeys).toContain("getMissionStatus"); - expect(toolKeys).toContain("updateMission"); - expect(toolKeys).toContain("launchMissionRun"); - expect(toolKeys).toContain("resolveMissionIntervention"); - expect(toolKeys).toContain("getMissionRunView"); - expect(toolKeys).toContain("getMissionLogs"); - expect(toolKeys).toContain("listMissionWorkerDigests"); - expect(toolKeys).toContain("steerMission"); - // Worker tools expect(toolKeys).toContain("listWorkers"); expect(toolKeys).toContain("createWorker"); @@ -147,7 +133,6 @@ describe("createCtoOperatorTools", () => { // Linear issue routing / issue tools expect(toolKeys).toContain("routeLinearIssueToCto"); - expect(toolKeys).toContain("routeLinearIssueToMission"); expect(toolKeys).toContain("routeLinearIssueToWorker"); expect(toolKeys).toContain("commentOnLinearIssue"); expect(toolKeys).toContain("updateLinearIssueState"); @@ -441,28 +426,19 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: true, lane }); }); - it("returns lane and mission navigation suggestions for operator-created ADE objects", async () => { + it("returns lane navigation suggestions for operator-created ADE objects", async () => { const lane = { id: "lane-2", name: "ops", branchRef: "refs/heads/ops" }; - const mission = { id: "mission-7", title: "Mission", laneId: "lane-2" }; const deps = buildDeps({ laneService: { list: vi.fn().mockResolvedValue([lane]), create: vi.fn().mockResolvedValue(lane), } as any, - missionService: { - create: vi.fn().mockReturnValue(mission), - } as any, }); const tools = createCtoOperatorTools(deps); const createdLane = await (tools.createLane as any).execute({ name: "ops", }); - const startedMission = await (tools.startMission as any).execute({ - prompt: "Investigate the failing deploy path.", - laneId: "lane-2", - launch: false, - }); expect(createdLane).toMatchObject({ success: true, @@ -474,16 +450,6 @@ describe("createCtoOperatorTools", () => { label: "Open lane", }, }); - expect(startedMission).toMatchObject({ - success: true, - navigation: { - surface: "missions", - laneId: "lane-2", - missionId: "mission-7", - href: "/missions?missionId=mission-7&laneId=lane-2", - label: "Open mission", - }, - }); }); it("handles lane creation errors gracefully", async () => { @@ -501,177 +467,6 @@ describe("createCtoOperatorTools", () => { }); }); - // ── Mission tools ─────────────────────────────────────────────── - - describe("mission tools", () => { - it("updates missions, relaunches runs, and resolves interventions through stable services", async () => { - const mission = { id: "mission-1", title: "Mission" }; - const intervention = { id: "int-1", status: "resolved" }; - const deps = buildDeps({ - missionService: { - update: vi.fn().mockReturnValue(mission), - get: vi.fn().mockReturnValue(mission), - resolveIntervention: vi.fn().mockReturnValue(intervention), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn().mockResolvedValue({ started: { run: { id: "run-1" } }, mission }), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const updated = await (tools.updateMission as any).execute({ - missionId: "mission-1", - title: "Updated title", - status: "in_progress", - outcomeSummary: "Working", - }); - const launched = await (tools.launchMissionRun as any).execute({ - missionId: "mission-1", - runMode: "manual", - }); - const resolved = await (tools.resolveMissionIntervention as any).execute({ - missionId: "mission-1", - interventionId: "int-1", - status: "resolved", - resolutionKind: "answer_provided", - note: "Use the existing implementation.", - }); - - expect((deps.missionService as any).update).toHaveBeenCalledWith({ - missionId: "mission-1", - title: "Updated title", - status: "in_progress", - outcomeSummary: "Working", - }); - expect((deps.aiOrchestratorService as any).startMissionRun).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - runMode: "manual", - metadata: { launchSource: "cto_operator_tools.launchMissionRun" }, - }), - ); - expect((deps.missionService as any).resolveIntervention).toHaveBeenCalledWith({ - missionId: "mission-1", - interventionId: "int-1", - status: "resolved", - resolutionKind: "answer_provided", - note: "Use the existing implementation.", - }); - expect(updated).toMatchObject({ success: true, mission }); - expect(launched).toMatchObject({ success: true, mission }); - expect(resolved).toMatchObject({ success: true, intervention }); - }); - - it("returns error when mission service is not available for listMissions", async () => { - const deps = buildDeps({ missionService: null }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.listMissions as any).execute({}); - - expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission service") }); - }); - - it("lists missions with filters", async () => { - const missions = [{ id: "m-1", status: "in_progress" }]; - const deps = buildDeps({ - missionService: { - list: vi.fn().mockReturnValue(missions), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.listMissions as any).execute({ status: "in_progress" }); - - expect(result).toMatchObject({ success: true, count: 1, missions }); - }); - - it("returns error when mission not found for getMissionStatus", async () => { - const deps = buildDeps({ - missionService: { - get: vi.fn().mockReturnValue(null), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.getMissionStatus as any).execute({ missionId: "nonexistent" }); - - expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission not found") }); - }); - - it("returns error when mission not found for launchMissionRun", async () => { - const deps = buildDeps({ - missionService: { - get: vi.fn().mockReturnValue(null), - } as any, - aiOrchestratorService: {} as any, - }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.launchMissionRun as any).execute({ missionId: "nonexistent" }); - - expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission not found") }); - }); - - it("surfaces mission runtime view, logs, worker digests, and steering through aiOrchestratorService", async () => { - const runView = { missionId: "mission-1", displayStatus: "running" }; - const logs = { entries: [{ id: "log-1" }], nextCursor: null, total: 1 }; - const digests = [{ id: "digest-1" }]; - const steerResult = { acknowledged: true, appliedAt: "2026-03-16T00:00:00.000Z" }; - const deps = buildDeps({ - aiOrchestratorService: { - getRunView: vi.fn().mockResolvedValue(runView), - getMissionLogs: vi.fn().mockResolvedValue(logs), - listWorkerDigests: vi.fn().mockReturnValue(digests), - steerMission: vi.fn().mockReturnValue(steerResult), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const view = await (tools.getMissionRunView as any).execute({ missionId: "mission-1" }); - const missionLogs = await (tools.getMissionLogs as any).execute({ - missionId: "mission-1", - channels: ["runtime"], - limit: 25, - }); - const workerDigests = await (tools.listMissionWorkerDigests as any).execute({ - missionId: "mission-1", - limit: 10, - }); - const steered = await (tools.steerMission as any).execute({ - missionId: "mission-1", - directive: "Pause on migration cleanup and summarize the risk.", - priority: "override", - }); - - expect(view).toMatchObject({ success: true, view: runView }); - expect(missionLogs).toMatchObject({ success: true, total: 1 }); - expect(workerDigests).toMatchObject({ success: true, count: 1, digests }); - expect(steered).toMatchObject({ success: true, result: steerResult }); - }); - - it("returns error when aiOrchestratorService is null for getMissionRunView", async () => { - const deps = buildDeps({ aiOrchestratorService: null }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.getMissionRunView as any).execute({ missionId: "m-1" }); - - expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission runtime service") }); - }); - - it("returns error when run view is null for getMissionRunView", async () => { - const deps = buildDeps({ - aiOrchestratorService: { - getRunView: vi.fn().mockResolvedValue(null), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.getMissionRunView as any).execute({ missionId: "m-1" }); - - expect(result).toMatchObject({ success: false, error: expect.stringContaining("Mission run view not found") }); - }); - }); - // ── Worker tools ──────────────────────────────────────────────── describe("worker tools", () => { @@ -1350,49 +1145,6 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: false, error: expect.stringContaining("Workflow run not found") }); }); - it("reroutes active Linear runs by cancelling first and then routing to a mission", async () => { - const mission = { id: "mission-9" }; - const deps = buildDeps({ - issueTracker: { - fetchIssueById: vi.fn().mockResolvedValue(issueFixture), - } as any, - missionService: { - create: vi.fn().mockReturnValue(mission), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn().mockResolvedValue({ started: { run: { id: "run-2" } }, mission }), - } as any, - flowPolicyService: { getPolicy: vi.fn().mockReturnValue({ workflows: [] }) } as any, - linearDispatcherService: { - getRunDetail: vi.fn().mockResolvedValue({ - run: { id: "run-1", issueId: "issue-1", status: "awaiting_delegation" }, - issue: { id: "issue-1" }, - }), - cancelRun: vi.fn().mockResolvedValue(undefined), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.rerouteLinearRun as any).execute({ - runId: "run-1", - target: "mission", - reason: "Delegate through mission planning instead.", - runMode: "autopilot", - }); - - expect((deps.linearDispatcherService as any).cancelRun).toHaveBeenCalledWith( - "run-1", - "Delegate through mission planning instead. (rerouted by CTO)", - { workflows: [] }, - ); - expect((deps.missionService as any).create).toHaveBeenCalled(); - expect(result).toMatchObject({ - success: true, - cancelledExistingRun: true, - rerouted: { success: true, mission }, - }); - }); - it("reroutes terminal Linear runs without cancelling and can hand them back to the CTO session", async () => { const deps = buildDeps({ issueTracker: { @@ -1493,28 +1245,6 @@ describe("createCtoOperatorTools", () => { expect(deps.ensureCtoSession).toHaveBeenCalled(); }); - it("routes a Linear issue to a mission", async () => { - const mission = { id: "m-1", laneId: "lane-1" }; - const deps = buildDeps({ - issueTracker: { - fetchIssueById: vi.fn().mockResolvedValue(issueFixture), - } as any, - missionService: { - create: vi.fn().mockReturnValue(mission), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn().mockResolvedValue({ mission }), - } as any, - }); - const tools = createCtoOperatorTools(deps); - - const result = await (tools.routeLinearIssueToMission as any).execute({ - issueId: "issue-1", - }); - - expect(result).toMatchObject({ success: true, mission }); - }); - it("returns error when issue tracker is not available for routing", async () => { const deps = buildDeps({ issueTracker: null }); const tools = createCtoOperatorTools(deps); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 6aeaab72e..e95ae15e9 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -39,8 +39,6 @@ import type { createWorkerHeartbeatService } from "../../cto/workerHeartbeatServ import type { createFlowPolicyService } from "../../cto/flowPolicyService"; import type { createFileService } from "../../files/fileService"; import type { createLaneService } from "../../lanes/laneService"; -import type { createMissionService } from "../../missions/missionService"; -import type { createAiOrchestratorService } from "../../orchestrator/aiOrchestratorService"; import type { createIssueInventoryService } from "../../prs/issueInventoryService"; import { computeConvergenceStatus, detectSource, extractSeverity } from "../../prs/issueInventoryService"; import { launchPrIssueResolutionChat } from "../../prs/prIssueResolver"; @@ -64,8 +62,6 @@ export interface CtoOperatorToolDeps { freshLaneDescription?: string | null; }) => Promise; laneService: ReturnType; - missionService?: ReturnType | null; - aiOrchestratorService?: ReturnType | null; workerAgentService?: ReturnType | null; workerHeartbeatService?: ReturnType | null; linearDispatcherService?: ReturnType | null; @@ -132,9 +128,6 @@ export interface CtoOperatorToolDeps { getBudgetSnapshot: (args: { monthKey?: string }) => any; listCostEvents: (args: { agentId: string; monthKey?: string; limit?: number }) => any[]; } | null; - missionBudgetService?: { - getMissionBudgetStatus: (args: { missionId: string }) => Promise; - } | null; issueTracker?: IssueTracker | null; ctoStateService?: Pick, "getSessionLogs" | "getSubordinateActivityLogs"> | null; listChats: (laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean }) => Promise; @@ -224,11 +217,9 @@ function buildNavigationSuggestion(args: { surface: OperatorNavigationSuggestion["surface"]; laneId?: string | null; sessionId?: string | null; - missionId?: string | null; }): OperatorNavigationSuggestion { const laneId = args.laneId?.trim() || null; const sessionId = args.sessionId?.trim() || null; - const missionId = args.missionId?.trim() || null; if (args.surface === "work") { const search = new URLSearchParams(); if (laneId) search.set("laneId", laneId); @@ -242,19 +233,6 @@ function buildNavigationSuggestion(args: { sessionId, }; } - if (args.surface === "missions") { - const search = new URLSearchParams(); - if (missionId) search.set("missionId", missionId); - if (laneId) search.set("laneId", laneId); - const query = search.toString(); - return { - surface: "missions", - label: "Open mission", - href: `/missions${query ? `?${query}` : ""}`, - laneId, - missionId, - }; - } if (args.surface === "cto") { return { surface: "cto", @@ -595,54 +573,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.issueTracker || !deps.missionService) { - return { success: false as const, error: "Mission routing services are not available." }; - } - const issue = await loadIssue(args.issueId); - if (!issue) return { success: false as const, error: `Issue not found: ${args.issueId}` }; - try { - const mission = deps.missionService.create({ - title: `${issue.identifier}: ${issue.title}`, - prompt: buildIssueBrief(issue), - laneId: args.laneId?.trim() || deps.defaultLaneId, - autostart: false, - launchMode: args.runMode ?? "autopilot", - }); - let run: unknown = null; - if ((args.launch ?? true) && deps.aiOrchestratorService) { - run = await deps.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: args.runMode ?? "autopilot", - autopilotOwnerId: "cto-linear-route", - defaultRetryLimit: 1, - metadata: { - launchSource: "cto_operator_tools.routeLinearIssueToMission", - linearIssueId: issue.id, - linearIssueIdentifier: issue.identifier, - }, - }); - } - return { - success: true as const, - mission, - run, - ...buildNavigationPayload(buildNavigationSuggestion({ - surface: "missions", - laneId: mission.laneId ?? (args.laneId?.trim() || deps.defaultLaneId), - missionId: mission.id, - })), - }; - } catch (error) { - return { success: false as const, error: getErrorMessage(error) }; - } - }; - const routeIssueToWorker = async (args: { issueId: string; agentId: string; @@ -897,305 +827,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.missionService) return { success: false, error: "Mission service is not available." }; - const missions = deps.missionService.list({ - ...(laneId?.trim() ? { laneId: laneId.trim() } : {}), - ...(status ? { status } : {}), - }); - return { success: true, count: missions.length, missions }; - }, - }); - - tools.startMission = tool({ - description: "Create a mission and optionally launch it through the orchestrator.", - inputSchema: z.object({ - prompt: z.string(), - title: z.string().optional(), - laneId: z.string().optional(), - priority: z.enum(["urgent", "high", "normal", "low"]).optional(), - launch: z.boolean().optional().default(true), - runMode: z.enum(["autopilot", "manual"]).optional().default("autopilot"), - }), - execute: async ({ prompt, title, laneId, priority, launch, runMode }) => { - if (!deps.missionService) return { success: false, error: "Mission service is not available." }; - try { - const executionLaneId = await deps.resolveExecutionLane({ - requestedLaneId: laneId?.trim() || undefined, - purpose: title?.trim() || "mission", - freshLaneName: title?.trim() || "mission", - freshLaneDescription: "Dedicated mission lane launched from the CTO coordinator chat.", - }); - const mission = deps.missionService.create({ - prompt, - ...(title?.trim() ? { title: title.trim() } : {}), - laneId: executionLaneId, - ...(priority ? { priority } : {}), - autostart: false, - launchMode: runMode, - }); - let run: unknown = null; - if (launch && deps.aiOrchestratorService) { - run = await deps.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode, - autopilotOwnerId: "cto-operator-tools", - defaultRetryLimit: 1, - metadata: { - launchSource: "cto_operator_tools.startMission", - }, - }); - } - return { - success: true, - mission, - run, - ...buildNavigationPayload(buildNavigationSuggestion({ - surface: "missions", - laneId: mission.laneId ?? executionLaneId, - missionId: mission.id, - })), - }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.getMissionStatus = tool({ - description: "Get mission detail so you can inspect steps, artifacts, and interventions.", - inputSchema: z.object({ - missionId: z.string(), - }), - execute: async ({ missionId }) => { - if (!deps.missionService) return { success: false, error: "Mission service is not available." }; - const mission = deps.missionService.get(missionId); - if (!mission) return { success: false, error: `Mission not found: ${missionId}` }; - return { - success: true, - mission, - ...buildNavigationPayload(buildNavigationSuggestion({ - surface: "missions", - laneId: mission.laneId ?? null, - missionId: mission.id, - })), - }; - }, - }); - - tools.updateMission = tool({ - description: "Apply stable mission edits such as title, prompt, lane, priority, status, or outcome summary.", - inputSchema: z.object({ - missionId: z.string(), - title: z.string().optional(), - prompt: z.string().optional(), - laneId: z.string().nullable().optional(), - status: z.enum(["queued", "planning", "in_progress", "intervention_required", "completed", "failed", "canceled"]).optional(), - priority: z.enum(["urgent", "high", "normal", "low"]).optional(), - outcomeSummary: z.string().nullable().optional(), - }), - execute: async ({ missionId, title, prompt, laneId, status, priority, outcomeSummary }) => { - if (!deps.missionService) return { success: false, error: "Mission service is not available." }; - try { - const mission = deps.missionService.update({ - missionId, - ...(title !== undefined ? { title } : {}), - ...(prompt !== undefined ? { prompt } : {}), - ...(laneId !== undefined ? { laneId } : {}), - ...(status ? { status } : {}), - ...(priority ? { priority } : {}), - ...(outcomeSummary !== undefined ? { outcomeSummary } : {}), - }); - return { - success: true, - mission, - ...buildNavigationPayload(buildNavigationSuggestion({ - surface: "missions", - laneId: mission.laneId ?? null, - missionId: mission.id, - })), - }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.launchMissionRun = tool({ - description: "Launch or relaunch orchestration for an existing mission.", - inputSchema: z.object({ - missionId: z.string(), - runMode: z.enum(["autopilot", "manual"]).optional().default("autopilot"), - plannerProvider: z.enum(["claude", "codex", "deterministic"]).optional(), - }), - execute: async ({ missionId, runMode, plannerProvider }) => { - if (!deps.missionService || !deps.aiOrchestratorService) { - return { success: false, error: "Mission runtime services are not available." }; - } - const mission = deps.missionService.get(missionId); - if (!mission) return { success: false, error: `Mission not found: ${missionId}` }; - try { - const run = await deps.aiOrchestratorService.startMissionRun({ - missionId, - runMode, - autopilotOwnerId: "cto-operator-tools", - defaultRetryLimit: 1, - ...(plannerProvider ? { plannerProvider } : {}), - metadata: { - launchSource: "cto_operator_tools.launchMissionRun", - }, - }); - return { - success: true, - mission, - run, - ...buildNavigationPayload(buildNavigationSuggestion({ - surface: "missions", - laneId: mission.laneId ?? null, - missionId: mission.id, - })), - }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.resolveMissionIntervention = tool({ - description: "Resolve an open mission intervention with an explicit status and resolution kind.", - inputSchema: z.object({ - missionId: z.string(), - interventionId: z.string(), - status: z.enum(["resolved", "dismissed"]), - resolutionKind: z.enum(["answer_provided", "accept_defaults", "skip_question", "cancel_run"]).nullable().optional(), - note: z.string().nullable().optional(), - }), - execute: async ({ missionId, interventionId, status, resolutionKind, note }) => { - if (!deps.missionService) return { success: false, error: "Mission service is not available." }; - try { - const intervention = deps.missionService.resolveIntervention({ - missionId, - interventionId, - status, - resolutionKind: resolutionKind ?? null, - note: note ?? null, - }); - return { success: true, intervention }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.getMissionRunView = tool({ - description: "Read the orchestrator-backed mission runtime summary.", - inputSchema: z.object({ - missionId: z.string(), - runId: z.string().nullable().optional(), - }), - execute: async ({ missionId, runId }) => { - if (!deps.aiOrchestratorService) return { success: false, error: "Mission runtime service is not available." }; - try { - const view = await deps.aiOrchestratorService.getRunView({ - missionId, - runId: runId?.trim() || null, - }); - if (!view) return { success: false, error: `Mission run view not found for mission ${missionId}.` }; - return { success: true, view }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.getMissionLogs = tool({ - description: "Read bounded mission logs across timeline, runtime, chat, outputs, and interventions.", - inputSchema: z.object({ - missionId: z.string(), - runId: z.string().nullable().optional(), - channels: z.array(z.enum(["timeline", "runtime", "chat", "outputs", "reflections", "retrospectives", "interventions"])).optional(), - cursor: z.string().nullable().optional(), - limit: z.number().int().positive().max(500).optional().default(100), - }), - execute: async ({ missionId, runId, channels, cursor, limit }) => { - if (!deps.aiOrchestratorService) return { success: false, error: "Mission runtime service is not available." }; - try { - const logs = await deps.aiOrchestratorService.getMissionLogs({ - missionId, - runId: runId?.trim() || null, - ...(channels?.length ? { channels } : {}), - cursor: cursor?.trim() || null, - limit, - }); - return { success: true, ...logs }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.listMissionWorkerDigests = tool({ - description: "List worker runtime digests for a mission so the CTO can supervise delegated execution.", - inputSchema: z.object({ - missionId: z.string(), - runId: z.string().nullable().optional(), - stepId: z.string().nullable().optional(), - attemptId: z.string().nullable().optional(), - laneId: z.string().nullable().optional(), - limit: z.number().int().positive().max(200).optional().default(50), - }), - execute: async ({ missionId, runId, stepId, attemptId, laneId, limit }) => { - if (!deps.aiOrchestratorService) return { success: false, error: "Mission runtime service is not available." }; - try { - const digests = deps.aiOrchestratorService.listWorkerDigests({ - missionId, - runId: runId?.trim() || null, - stepId: stepId?.trim() || null, - attemptId: attemptId?.trim() || null, - laneId: laneId?.trim() || null, - limit, - }); - return { success: true, count: digests.length, digests }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.steerMission = tool({ - description: "Send a follow-up directive into a live mission run without opening raw coordinator internals.", - inputSchema: z.object({ - missionId: z.string(), - directive: z.string(), - priority: z.enum(["suggestion", "instruction", "override"]).optional().default("instruction"), - targetStepKey: z.string().nullable().optional(), - interventionId: z.string().nullable().optional(), - resolutionKind: z.enum(["answer_provided", "accept_defaults", "skip_question", "cancel_run"]).nullable().optional(), - }), - execute: async ({ missionId, directive, priority, targetStepKey, interventionId, resolutionKind }) => { - if (!deps.aiOrchestratorService) return { success: false, error: "Mission runtime service is not available." }; - try { - const result = deps.aiOrchestratorService.steerMission({ - missionId, - directive, - priority, - targetStepKey: targetStepKey?.trim() || null, - interventionId: interventionId?.trim() || null, - resolutionKind: resolutionKind ?? null, - }); - return { success: true, result }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - tools.listWorkers = tool({ description: "List worker agents in the CTO org.", inputSchema: z.object({ @@ -1936,17 +1567,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record routeIssueToCto({ issueId, laneId, reuseExisting }), }); - tools.routeLinearIssueToMission = tool({ - description: "Create a mission from a Linear issue and optionally launch it.", - inputSchema: z.object({ - issueId: z.string(), - laneId: z.string().optional(), - launch: z.boolean().optional().default(true), - runMode: z.enum(["autopilot", "manual"]).optional().default("autopilot"), - }), - execute: async ({ issueId, laneId, launch, runMode }) => routeIssueToMission({ issueId, laneId, launch, runMode }), - }); - tools.routeLinearIssueToWorker = tool({ description: "Wake a worker agent with a Linear issue as the task context.", inputSchema: z.object({ @@ -1961,16 +1581,14 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + execute: async ({ runId, target, reason, laneId, reuseExisting, agentId, taskKey }) => { if (!deps.linearDispatcherService || !deps.flowPolicyService) { return { success: false, error: "Linear workflow services are not available." }; } @@ -1993,9 +1611,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - let missions: { - byStatus: Record; - total: number; - activeCount: number; - openInterventions: number; - weekly: { missions: number; successRate: number; avgDurationMs: number; totalCostUsd: number } | null; - } | null = null; - if (deps.missionService) { - const all = deps.missionService.list({ includeArchived: false, limit: 500 }); - const byStatus: Record = {}; - let openInterventions = 0; - for (const m of all) { - byStatus[m.status] = (byStatus[m.status] ?? 0) + 1; - openInterventions += m.openInterventions; - } - const activeCount = all.filter( - (m) => m.status === "queued" || m.status === "planning" || m.status === "in_progress" || m.status === "intervention_required", - ).length; - let weekly: { missions: number; successRate: number; avgDurationMs: number; totalCostUsd: number } | null = null; - try { - const dashboard = (deps.missionService as any).getDashboard?.(); - if (dashboard?.weekly) weekly = dashboard.weekly; - } catch { /* non-fatal */ } - missions = { byStatus, total: all.length, activeCount, openInterventions, weekly }; - } - let workers: { total: number; byStatus: Record; @@ -3192,13 +2760,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record (l as any).missionId).length, }; return { success: true, generatedAt: nowIso(), - missions, workers, tests, prs, @@ -3219,7 +2785,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + execute: async ({ monthKey }) => { if (!deps.workerBudgetService) return { success: false, error: "Worker budget service is not available." }; try { const snapshot = deps.workerBudgetService.getBudgetSnapshot({ monthKey: monthKey?.trim() || undefined }); - let missionBudget: any = null; - if (missionId?.trim() && deps.missionBudgetService) { - try { - missionBudget = await deps.missionBudgetService.getMissionBudgetStatus({ missionId: missionId.trim() }); - } catch { /* non-fatal */ } - } return { success: true, monthKey: snapshot.monthKey, @@ -3351,7 +2910,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { expect(result).toContain("## Editing Rules"); expect(result).toContain("## Verification Rules"); expect(result).toContain("## User-Facing Progress"); - expect(result).toContain("## Mission"); + expect(result).toContain("## Task"); }); it("uses the active cwd when describing ADE skill roots", () => { diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index d10f68e76..0e702d6cc 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -126,7 +126,7 @@ export function buildCodingAgentSystemPrompt(args: { ] : []), "", - "## Mission", + "## Task", describeMode(mode), describePermissionMode(permissionMode), "", diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.test.ts b/apps/desktop/src/main/services/ai/tools/universalTools.test.ts index c1f8ed74b..29cb7a19e 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WorkerSandboxConfig } from "../../../../shared/types"; -import { DEFAULT_WORKER_SANDBOX_CONFIG } from "../../orchestrator/orchestratorConstants"; +import { DEFAULT_WORKER_SANDBOX_CONFIG } from "./workerSandboxDefaults"; import { checkWorkerSandbox, createUniversalToolSet, diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.ts b/apps/desktop/src/main/services/ai/tools/universalTools.ts index cb3ec1e28..648e3e9a3 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.ts @@ -11,7 +11,7 @@ import { createGlobSearchTool } from "./globSearch"; import { webFetchTool } from "./webFetch"; import { webSearchTool } from "./webSearch"; import type { AgentChatApprovalDecision, AgentChatEvent, WorkerSandboxConfig } from "../../../../shared/types"; -import { DEFAULT_WORKER_SANDBOX_CONFIG } from "../../orchestrator/orchestratorConstants"; +import { DEFAULT_WORKER_SANDBOX_CONFIG } from "./workerSandboxDefaults"; import { getErrorMessage, isEnoentError, isWithinDir, resolvePathWithinRoot } from "../../shared/utils"; import { terminateProcessTree } from "../../shared/processExecution"; diff --git a/apps/desktop/src/main/services/ai/tools/workerSandboxDefaults.ts b/apps/desktop/src/main/services/ai/tools/workerSandboxDefaults.ts new file mode 100644 index 000000000..b19d8ca2b --- /dev/null +++ b/apps/desktop/src/main/services/ai/tools/workerSandboxDefaults.ts @@ -0,0 +1,83 @@ +import type { WorkerSandboxConfig } from "../../../../shared/types"; + +export const DEFAULT_WORKER_SANDBOX_CONFIG: WorkerSandboxConfig = { + blockedCommands: [ + "\\brm\\s+-rf\\s+/", + "\\brm\\s+-rf\\s+~", + "\\bsudo\\b", + "\\bchmod\\s+777\\b", + "\\bcurl\\b.*\\|\\s*sh", + "\\bwget\\b.*\\|\\s*sh", + "\\beval\\b", + ">\\s*/etc/", + ">\\s*/usr/", + ">\\s*/var/", + "\\bmkfs\\b", + "\\bdd\\b\\s+if=", + "\\bshutdown\\b", + "\\breboot\\b", + ":\\(\\)\\{", + "\\breg(?:\\.exe)?\\s+(add|delete|import|load|unload|copy|save|restore)\\b", + "\\bdiskpart(?:\\.exe)?\\b", + "\\bformat(?:\\.exe)?\\s+[a-z]:", + "\\bbcdedit(?:\\.exe)?\\b", + "\\btakeown(?:\\.exe)?\\b", + ">\\s*[^\\n\\r]*[/\\\\]windows[/\\\\]system32\\b", + ">\\s*[^\\n\\r]*[/\\\\]windows[/\\\\]syswow64\\b", + ], + safeCommands: [ + "^pnpm(\\.cmd)?\\s", + "^npm(\\.cmd)?\\s", + "^yarn(\\.cmd)?\\s", + "^npx(\\.cmd)?\\s", + "^git(\\.exe)?\\s+status\\b", + "^git(\\.exe)?\\s+diff\\b", + "^git(\\.exe)?\\s+log\\b", + "^git(\\.exe)?\\s+show\\b", + "^git(\\.exe)?\\s+branch\\s*$", + "^git(\\.exe)?\\s+ls-files\\b", + "^ls\\s", + "^ls$", + "^pwd\\b", + "^echo\\s", + "^date\\b", + "^node(\\.exe)?\\s", + "^tsx(\\.cmd)?\\s", + "^vitest(\\.cmd)?\\s", + "^jest(\\.cmd)?\\s", + "^eslint(\\.cmd)?\\s", + "^prettier(\\.cmd)?\\s", + "^tsc(\\.cmd)?\\b", + "^lsof\\s", + "^ps\\s", + ], + protectedFiles: [ + "\\.env$", + "\\.env\\.", + "secrets?\\.json$", + "credentials\\.json$", + "\\.pem$", + "\\.key$", + "/\\.git/", + ], + allowedPaths: ["./"], + blockByDefault: false, +}; + +const CLAUDE_READ_ONLY_NATIVE_TOOLS = [ + "Read", + "Glob", + "Grep", +] as const; + +export function buildClaudeReadOnlyWorkerAllowedTools(extraToolNames: readonly string[] = []): string[] { + const seen = new Set(); + const out: string[] = []; + for (const entry of [...CLAUDE_READ_ONLY_NATIVE_TOOLS, ...extraToolNames]) { + const trimmed = entry.trim(); + if (!trimmed.length || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts index 8f4b04f37..1cf698ca5 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts @@ -209,11 +209,9 @@ describe("automationPlannerService.validateDraft", () => { triggers: [{ type: "git.pr_opened", branch: "feat/*" }], trigger: { type: "git.pr_opened", branch: "feat/*" }, modelConfig: { - orchestratorModel: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high", - }, - } as any, + } as any, permissionConfig: { providers: { opencode: "full-auto", @@ -225,7 +223,7 @@ describe("automationPlannerService.validateDraft", () => { }); const saved = planner.saveDraft({ draft, confirmations: [] }); - expect(saved.rule.modelConfig?.orchestratorModel.modelId).toBe("opencode/openai/gpt-5.4"); + expect(saved.rule.modelConfig?.modelId).toBe("opencode/openai/gpt-5.4"); expect(saved.rule.permissionConfig?.providers?.allowedTools).toEqual(["git", "linear"]); expect(saved.rule.actions[0]?.type).toBe("run-tests"); expect(getSnapshot().local.automations[0]?.actions?.[0]?.type).toBe("run-tests"); diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 1914cdd5c..51037572a 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -706,8 +706,7 @@ function normalizeDraft(args: { type !== "run-tests" && type !== "run-command" && type !== "ade-action" && - type !== "agent-session" && - type !== "launch-mission" + type !== "agent-session" ) { issues.push({ level: "error", path: `actions[${idx}].type`, message: `Unknown action type '${safeTrim(action?.type)}'.` }); continue; @@ -759,14 +758,6 @@ function normalizeDraft(args: { continue; } - if (type === "launch-mission") { - normalizedActions.push({ - ...(base as AutomationAction), - ...(safeTrim(action?.sessionTitle) ? { sessionTitle: safeTrim(action?.sessionTitle) } : {}), - }); - continue; - } - if (type === "run-tests") { const suiteQuery = safeTrim(action?.suite); if (!suiteQuery) { @@ -856,7 +847,7 @@ function normalizeDraft(args: { : {}), }; const execution = - requestedExecution?.kind === "agent-session" || requestedExecution?.kind === "mission" || requestedExecution?.kind === "built-in" + requestedExecution?.kind === "agent-session" || requestedExecution?.kind === "built-in" ? { kind: requestedExecution.kind, ...laneExecutionFields, @@ -874,13 +865,6 @@ function normalizeDraft(args: { }, } : {}), - ...(requestedExecution.kind === "mission" - ? { - mission: { - ...(safeTrim(requestedExecution.mission?.title) ? { title: safeTrim(requestedExecution.mission?.title) } : {}), - }, - } - : {}), ...(requestedExecution.kind === "built-in" ? { builtIn: { actions: normalizedActions } } : {}), } : normalizedActions.length > 0 @@ -935,7 +919,7 @@ function normalizeDraft(args: { : "quick", toolPalette: Array.isArray(args.draft.toolPalette) && args.draft.toolPalette.length ? [...new Set(args.draft.toolPalette)] - : ["repo", "mission"], + : ["repo"], contextSources: includeProjectContext && Array.isArray(args.draft.contextSources) && args.draft.contextSources.length ? args.draft.contextSources.map((source) => ({ type: source.type, @@ -1033,7 +1017,7 @@ function createEmptyDraft(): AutomationRuleDraft { execution: { kind: "agent-session", session: {} }, executor: { mode: "automation-bot" }, reviewProfile: "quick", - toolPalette: ["repo", "mission"], + toolPalette: ["repo"], contextSources: [], guardrails: {}, outputs: { disposition: "comment-only", createArtifact: true }, @@ -1372,13 +1356,6 @@ export function createAutomationPlannerService({ summary: "Send the prompt to an automation-only agent thread", warnings: [], }); - } else if (execution.kind === "mission") { - actions.unshift({ - index: -1, - type: "launch-mission", - summary: "Launch a mission run with the selected model and permissions", - warnings: [], - }); } const notes: string[] = []; @@ -1394,11 +1371,8 @@ export function createAutomationPlannerService({ if (execution.kind === "agent-session") { notes.push("Run output stays in Automations history and does not appear in Work chat."); } - if (execution.kind === "mission") { - notes.push("Mission runs stay visible from the Missions tab."); - } if (execution.kind === "built-in") { - notes.push("Built-in tasks run directly without launching a mission or chat thread."); + notes.push("Built-in tasks run directly without launching a chat thread."); } if (execution.laneMode === "require-on-trigger") { notes.push("Lane resolution: trigger caller must supply a lane."); diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index a4ffbc713..ea04f6c86 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -112,7 +112,6 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { project_id text not null, automation_id text not null, chat_session_id text, - mission_id text, worker_run_id text, worker_agent_id text, queue_item_id text, @@ -511,320 +510,6 @@ describe("automationService integration", () => { } }); - it("launches mission automations on the configured target lane", async () => { - const { db } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-mission-lane-")); - const createMission = vi.fn(() => ({ - id: "mission-1", - status: "in_progress", - outcomeSummary: null, - completedAt: null, - lastError: null, - })); - const patchMetadata = vi.fn(); - const startMissionRun = vi.fn(async () => undefined); - - const rule = { - id: "mission-lane", - name: "Mission lane", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { - kind: "mission" as const, - targetLaneId: "lane-target", - }, - prompt: "Run a mission on the target lane.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [{ id: "lane-primary", laneType: "primary" }, { id: "lane-target", laneType: "child" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - missionService: { - create: createMission, - patchMetadata, - } as any, - aiOrchestratorService: { - startMissionRun, - } as any, - }); - - try { - const run = await service.triggerManually({ id: "mission-lane", laneId: "lane-primary" }); - expect(run.status).toBe("running"); - expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ - laneId: "lane-target", - })); - const missionArgs = (createMission as any).mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(missionArgs?.prompt).toContain("Lane ID: lane-target"); - expect(missionArgs?.prompt).not.toContain("Lane ID: lane-primary"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("launches built-in mission actions instead of skipping them as unknown", async () => { - const { db, raw } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-")); - const createMission = vi.fn(() => ({ - id: "mission-built-in", - status: "in_progress", - outcomeSummary: null, - completedAt: null, - lastError: null, - })); - - const rule = { - id: "built-in-mission", - name: "Built-in mission", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { - kind: "built-in" as const, - builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }] }, - }, - actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }], - prompt: "Run a mission from a built-in action.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [{ id: "lane-primary", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - missionService: { - create: createMission, - patchMetadata: vi.fn(), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn(async () => undefined), - } as any, - }); - - try { - const run = await service.triggerManually({ id: "built-in-mission" }); - expect(run.status).toBe("running"); - expect(run.missionId).toBe("mission-built-in"); - expect(createMission).toHaveBeenCalled(); - const actions = mapExecRows(raw.exec("select action_type, status, output from automation_action_results")); - expect(actions).toHaveLength(1); - expect(actions[0]?.action_type).toBe("launch-mission"); - expect(String(actions[0]?.output ?? "")).toContain("mission-built-in"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("uses the step lane override when a built-in action launches a mission", async () => { - const { db } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-lane-")); - const createMission = vi.fn(() => ({ - id: "mission-built-in-lane", - status: "in_progress", - outcomeSummary: null, - completedAt: null, - lastError: null, - })); - - const rule = { - id: "built-in-mission-lane", - name: "Built-in mission lane", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { - kind: "built-in" as const, - targetLaneId: "lane-rule", - builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission", targetLaneId: "lane-action" }] }, - }, - actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission", targetLaneId: "lane-action" }], - prompt: "Run a mission from a built-in action.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [ - { id: "lane-primary", laneType: "primary" }, - { id: "lane-rule", laneType: "worktree" }, - { id: "lane-action", laneType: "worktree" }, - ], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - missionService: { - create: createMission, - patchMetadata: vi.fn(), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn(async () => undefined), - } as any, - }); - - try { - await service.triggerManually({ id: "built-in-mission-lane" }); - expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-action" })); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("fails built-in launch-mission actions when required trigger lane is missing", async () => { - const { db, raw } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-require-lane-")); - const createMission = vi.fn(); - - const rule = { - id: "built-in-mission-require-lane", - name: "Built-in mission require lane", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "github.issue_opened" as const }, - triggers: [{ type: "github.issue_opened" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { - kind: "built-in" as const, - laneMode: "require-on-trigger" as const, - builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }] }, - }, - actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }], - prompt: "Run a mission from a built-in action.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [{ id: "lane-primary", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - missionService: { - create: createMission, - patchMetadata: vi.fn(), - } as any, - aiOrchestratorService: { - startMissionRun: vi.fn(async () => undefined), - } as any, - }); - - try { - await service.dispatchIngressTrigger({ - source: "github-polling", - eventKey: "issue:built-in-mission-require-lane", - triggerType: "github.issue_opened", - eventName: "github.issue_opened", - issue: { number: 1, title: "No lane", labels: [] }, - } as any); - expect(createMission).not.toHaveBeenCalled(); - const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'built-in-mission-require-lane'")); - expect(runs[0]?.status).toBe("failed"); - expect(String(runs[0]?.error_message ?? "")).toContain("trigger payload to include a laneId"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - it("attaches built-in agent-session actions to the automation run", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -920,9 +605,7 @@ describe("automationService integration", () => { trigger: { type: "github.issue_opened" as const }, triggers: [{ type: "github.issue_opened" as const }], executor: { mode: "automation-bot", targetId: null }, - modelConfig: { - orchestratorModel: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "medium" }, - }, + modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "medium" }, permissionConfig: { providers: { opencode: "edit" } }, toolPalette: [] as const, contextSources: [], @@ -1304,9 +987,7 @@ describe("automationService integration", () => { trigger: { type: "manual" as const }, triggers: [{ type: "manual" as const }], executor: { mode: "automation-bot", targetId: null }, - modelConfig: { - orchestratorModel: { modelId: "openai/gpt-5.5", thinkingLevel: "xhigh" as const }, - }, + modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "xhigh" as const }, permissionConfig: { providers: { codex: "default" as const } }, toolPalette: [] as const, contextSources: [], @@ -1584,9 +1265,7 @@ describe("automationService integration", () => { trigger: { type: "manual" as const }, triggers: [{ type: "manual" as const }], executor: { mode: "automation-bot", targetId: null }, - modelConfig: { - orchestratorModel: { modelId: "openai/gpt-5.4", thinkingLevel: "low" }, - }, + modelConfig: { modelId: "openai/gpt-5.4", thinkingLevel: "low" }, permissionConfig: { providers: { codex: "default" as const, opencode: "edit" as const } }, toolPalette: [] as const, contextSources: [], @@ -1682,11 +1361,9 @@ describe("automationService integration", () => { kind: "agent-session" as const, }, modelConfig: { - orchestratorModel: { modelId: "openai/gpt-5.4", thinkingLevel: "medium", }, - }, prompt: "Summarize the current state.", }; @@ -1744,78 +1421,6 @@ describe("automationService integration", () => { return { db, raw, logger, projectId, projectRoot }; } - it("creates a fresh lane via preset when laneMode is 'create' and emits a lane-setup row", async () => { - const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); - const createLane = vi.fn(async ({ name }: { name: string }) => ({ - id: "lane-fresh", - name, - branchRef: name.replace(/\s+/g, "-").toLowerCase(), - laneType: "feature", - worktreePath: projectRoot, - })); - const createMission = vi.fn(() => ({ id: "mission-x", status: "in_progress", outcomeSummary: null, completedAt: null, lastError: null })); - - const rule = { - id: "issue-create-lane", - name: "Issue create lane", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { - kind: "mission" as const, - laneMode: "create" as const, - laneNamePreset: "issue-title" as const, - }, - prompt: "Run the mission.", - }; - - const projectConfigService = { - get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) - } as any; - const laneService = { - create: createLane, - list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, - missionService: { create: createMission, patchMetadata: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, - }); - - try { - // Inject an issue payload by stuffing trigger context via dispatchIngressTrigger. - // Manual trigger would have no issue payload — use a manual call but seed the - // trigger via service.triggerManually then inspect the create call. - // Instead, directly hit the underlying path by manipulating triggers: use - // triggerManually here and the createLaneForRun fallback (rule.name) will fire. - const run = await service.triggerManually({ id: "issue-create-lane" }); - expect(run.status).toBe("running"); - expect(createLane).toHaveBeenCalledTimes(1); - const args = (createLane as any).mock.calls[0]?.[0] as { name: string }; - // No issue payload on manual triggers — falls back to rule.name. - expect(args.name).toBe("Issue create lane"); - expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-fresh" })); - - const setupRows = mapExecRows(raw.exec("select status, action_type from automation_action_results where action_type = 'lane-setup'")); - expect(setupRows.length).toBe(1); - expect(setupRows[0]?.status).toBe("succeeded"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - it("reuses the created lane for every built-in step in the run", async () => { const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); const laneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-lane-mode-created-")); @@ -1891,198 +1496,6 @@ describe("automationService integration", () => { } }); - it("appends issue number on collision then a random suffix on a second collision", async () => { - const { db, logger, projectId, projectRoot } = buildLaneModeFixtures(); - const createLane = vi.fn(async ({ name }: { name: string }) => ({ - id: `lane-${name}`, - name, - branchRef: name.replace(/\s+/g, "-").toLowerCase(), - laneType: "feature", - worktreePath: projectRoot, - })); - const createMission = vi.fn(() => ({ id: "mission-x", status: "in_progress", outcomeSummary: null, completedAt: null, lastError: null })); - - // Two existing lanes already collide with "Fix login" AND "Fix login (#427)". - const rule = { - id: "issue-collide", - name: "Issue collide", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "github.issue_opened" as const }, - triggers: [{ type: "github.issue_opened" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { kind: "mission" as const, laneMode: "create" as const, laneNamePreset: "issue-title" as const }, - prompt: "Run.", - }; - - const projectConfigService = { - get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) - } as any; - - const laneService = { - create: createLane, - list: async () => [ - { id: "lane-primary", name: "primary", laneType: "primary" }, - { id: "lane-existing", name: "Fix login", laneType: "feature" }, - { id: "lane-existing-2", name: "Fix login (#427)", laneType: "feature" }, - ], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, - missionService: { create: createMission, patchMetadata: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, - }); - - try { - await service.dispatchIngressTrigger({ - source: "github-polling", - eventKey: "x:1", - triggerType: "github.issue_opened", - eventName: "github.issue_opened", - repo: "x/y", - issue: { number: 427, title: "Fix login", author: "a", labels: [], repo: "x/y", url: "https://x" } - } as any); - const args = (createLane as any).mock.calls[0]?.[0] as { name: string }; - // Both "Fix login" and "Fix login (#427)" already exist → falls through to random suffix. - expect(args.name).toMatch(/^Fix login \([0-9a-f]{4}\)$/); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("marks the run failed (no fallback to primary) when createLaneForRun throws", async () => { - const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); - const createLane = vi.fn(async () => { throw new Error("Disk full"); }); - const createMission = vi.fn(); - - const rule = { - id: "issue-fail", - name: "Issue fail", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [] as const, - contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { kind: "mission" as const, laneMode: "create" as const, laneNamePreset: "issue-title" as const }, - prompt: "Run.", - }; - - const projectConfigService = { - get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) - } as any; - const laneService = { - create: createLane, - list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, - missionService: { create: createMission, patchMetadata: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, - }); - - try { - await expect(service.triggerManually({ id: "issue-fail" })).rejects.toThrow("Disk full"); - expect(createMission).not.toHaveBeenCalled(); - const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'issue-fail'")); - expect(runs.length).toBe(1); - expect(runs[0]?.status).toBe("failed"); - expect(String(runs[0]?.error_message ?? "")).toContain("Disk full"); - const setupRows = mapExecRows(raw.exec("select status from automation_action_results where action_type = 'lane-setup'")); - expect(setupRows[0]?.status).toBe("failed"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - }); - - describe("legacy create-lane migration", () => { - it("collapses a leading create-lane action into laneMode: 'create' on load", async () => { - // Drive the migration through projectConfigService — but the service in tests - // gets a stub config service. Instead, exercise the same coercion logic by - // building a rule whose execution lacks laneMode and whose first action is - // create-lane, then verify the runtime behavior matches "create" mode. - const { db, logger, projectId, projectRoot } = (() => { - const { db } = createInMemoryAdeDb(); - return { db, logger: createLogger(), projectId: "proj", projectRoot: fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-migrate-")) }; - })(); - // Simulate what projectConfigService.coerce would have produced: - const migratedExecution = { - kind: "built-in" as const, - laneMode: "create" as const, - laneNamePreset: "custom" as const, - laneNameTemplate: "Auto: {{trigger.issue.title}}", - builtIn: { actions: [{ type: "create-lane" as const, laneNameTemplate: "Auto: {{trigger.issue.title}}" }] }, - }; - const createLane = vi.fn(async ({ name }: { name: string }) => ({ - id: "lane-migrated", - name, - branchRef: name.replace(/\s+/g, "-").toLowerCase(), - laneType: "feature", - worktreePath: projectRoot, - })); - const createMission = vi.fn(() => ({ id: "m", status: "in_progress", outcomeSummary: null, completedAt: null, lastError: null })); - const rule = { - id: "migrated", - name: "Migrated", - enabled: true, mode: "review", reviewProfile: "quick", - trigger: { type: "manual" as const }, triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: [], contextSources: [], - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" }, - billingCode: "auto:test", - // Migrated rule still keeps the legacy action so unmigrated runners can read it, - // but execution.laneMode === "create" steers the new path. - execution: { ...migratedExecution, kind: "mission" as const }, - prompt: "Run.", - }; - const projectConfigService = { - get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) - } as any; - const laneService = { - create: createLane, - list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - const service = createAutomationService({ - db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, - missionService: { create: createMission, patchMetadata: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, - }); - try { - await service.triggerManually({ id: "migrated" }); - expect(createLane).toHaveBeenCalledTimes(1); - // Manual trigger has no issue.title → embedded placeholder resolves to - // empty, leaving the literal prefix "Auto:" — verify the migrated path - // produced *some* lane and the leading template was honored. - const args = (createLane as any).mock.calls[0]?.[0] as { name: string }; - expect(args.name).toMatch(/^Auto:/); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); }); }); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 0303e399b..27c9c9df0 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -35,12 +35,10 @@ import type { createProjectConfigService } from "../config/projectConfigService" import type { createConflictService } from "../conflicts/conflictService"; import { withMacosSafeChokidarOptions } from "../shared/chokidarOptions"; import type { createTestService } from "../tests/testService"; -import type { createMissionService } from "../missions/missionService"; -import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createBudgetCapService } from "../usage/budgetCapService"; import type { BudgetCapProvider } from "../../../shared/types/usage"; -import { buildClaudeReadOnlyWorkerAllowedTools } from "../orchestrator/providerOrchestratorAdapter"; +import { buildClaudeReadOnlyWorkerAllowedTools } from "../ai/tools/workerSandboxDefaults"; import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; import { isRecord, matchesGlob, normalizeSet, nowIso, resolvePathWithinRoot, safeJsonParse } from "../shared/utils"; import { terminateProcessTree } from "../shared/processExecution"; @@ -143,7 +141,6 @@ type AutomationRunRow = { id: string; automation_id: string; chat_session_id: string | null; - mission_id: string | null; worker_run_id: string | null; worker_agent_id: string | null; queue_item_id: string | null; @@ -182,7 +179,6 @@ type AutomationQueueItemRow = { id: string; automation_id: string; run_id: string | null; - mission_id: string | null; position: number | null; title: string; mode: string; @@ -251,16 +247,6 @@ const TOOL_FAMILY_ALLOWED_TOOLS: Record = { "browser_type", "browser_take_screenshot", ], - mission: [ - "get_mission", - "get_run_graph", - "stream_events", - "get_timeline", - "get_pending_messages", - "report_status", - "report_result", - "ask_user", - ], }; function safeJsonParseRecord(raw: string | null): Record | null { @@ -615,7 +601,7 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { const includeProjectContext = deriveIncludeProjectContext(rule); const rawExecution = rule.execution ?? (legacyActions.length > 0 ? { kind: "built-in" as const, builtIn: { actions: legacyActions } } - : { kind: "mission" as const }); + : { kind: "agent-session" as const, session: {} }); const laneMode = normalizeAutomationLaneMode(rawExecution.laneMode); const sharedLaneFields = { ...(laneMode ? { laneMode } : {}), @@ -633,19 +619,12 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { actions: rawExecution.builtIn?.actions?.length ? rawExecution.builtIn.actions : legacyActions, }, } - : rawExecution.kind === "agent-session" - ? { - kind: "agent-session", - ...sharedLaneFields, - ...(rawExecution.targetLaneId ? { targetLaneId: rawExecution.targetLaneId } : {}), - ...(rawExecution.session ? { session: rawExecution.session } : {}), - } - : { - kind: "mission", - ...sharedLaneFields, - ...(rawExecution.targetLaneId ? { targetLaneId: rawExecution.targetLaneId } : {}), - ...(rawExecution.mission ? { mission: rawExecution.mission } : {}), - }; + : { + kind: "agent-session", + ...sharedLaneFields, + ...(rawExecution.targetLaneId ? { targetLaneId: rawExecution.targetLaneId } : {}), + ...(rawExecution.session ? { session: rawExecution.session } : {}), + }; const outputDisposition = rule.outputs?.disposition ?? "comment-only"; // Per-rule budget fields are deprecated in favor of global usage caps. We // keep them in YAML for downgrade compatibility, but do not let runtime @@ -665,7 +644,7 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { trigger: primary, executor: { mode: "automation-bot" }, reviewProfile: rule.reviewProfile ?? "quick", - toolPalette: rule.toolPalette?.length ? rule.toolPalette : ["repo", "mission"], + toolPalette: rule.toolPalette?.length ? rule.toolPalette : ["repo"], contextSources: includeProjectContext && rule.contextSources?.length ? rule.contextSources : [], guardrails: sanitizedGuardrails, includeProjectContext, @@ -688,11 +667,11 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { } function resolveExecutionKind(rule: AutomationRule): AutomationExecution["kind"] { - return rule.execution?.kind ?? ((rule.legacy?.actions?.length ?? 0) > 0 ? "built-in" : "mission"); + return rule.execution?.kind ?? ((rule.legacy?.actions?.length ?? 0) > 0 ? "built-in" : "agent-session"); } function summarizeLegacyActions(actions: AutomationAction[]): string { - if (!actions.length) return "mission dispatch"; + if (!actions.length) return "agent session"; return actions.map((action) => action.type).join(", "); } @@ -724,26 +703,6 @@ function resolveTemplateString(template: string | undefined | null, trigger: Tri } } -function mapMissionStatus(status: string, _verificationRequired: boolean): AutomationRunStatus { - switch (status) { - case "queued": - case "planning": - return "queued"; - case "in_progress": - return "running"; - case "intervention_required": - return "paused"; - case "completed": - return "succeeded"; - case "failed": - return "failed"; - case "canceled": - return "cancelled"; - default: - return "running"; - } -} - function mapWorkerStatus(status: string, _verificationRequired: boolean): AutomationRunStatus { switch (status) { case "queued": @@ -827,12 +786,11 @@ function toRun(row: AutomationRunRow): AutomationRun { id: row.id, automationId: row.automation_id, chatSessionId: row.chat_session_id ?? null, - missionId: row.mission_id ?? null, triggerType: (row.trigger_type as AutomationTriggerType) ?? "manual", startedAt: row.started_at, endedAt: row.ended_at ?? null, status: normalizeRunStatus(row.status, "failed"), - executionKind: (row.execution_kind as AutomationExecution["kind"]) ?? "mission", + executionKind: (row.execution_kind as AutomationExecution["kind"]) ?? "agent-session", actionsCompleted: row.actions_completed ?? 0, actionsTotal: row.actions_total ?? 0, errorMessage: row.error_message ?? null, @@ -868,8 +826,6 @@ export function createAutomationService({ conflictService, testService, agentChatService, - missionService, - aiOrchestratorService, budgetCapService, adeActionRegistry, onEvent @@ -883,8 +839,6 @@ export function createAutomationService({ conflictService?: ReturnType; testService?: ReturnType; agentChatService?: ReturnType; - missionService?: ReturnType; - aiOrchestratorService?: ReturnType; budgetCapService?: ReturnType; /** * Registry that resolves `ade-action` automation steps to a concrete @@ -903,8 +857,6 @@ export function createAutomationService({ githubRelay?: Partial; localWebhook?: Partial; }; - let missionServiceRef = missionService; - let aiOrchestratorServiceRef = aiOrchestratorService; let agentChatServiceRef = agentChatService; let budgetCapServiceRef = budgetCapService; let workerHeartbeatServiceRef: ReturnType | null = null; @@ -968,7 +920,6 @@ export function createAutomationService({ const ensureSchema = () => { const runColumns = [ ["chat_session_id", "alter table automation_runs add column chat_session_id text"], - ["mission_id", "alter table automation_runs add column mission_id text"], ["worker_run_id", "alter table automation_runs add column worker_run_id text"], ["worker_agent_id", "alter table automation_runs add column worker_agent_id text"], ["queue_item_id", "alter table automation_runs add column queue_item_id text"], @@ -993,7 +944,6 @@ export function createAutomationService({ project_id text not null, automation_id text not null, run_id text, - mission_id text, position integer not null default 0, title text not null, mode text not null, @@ -1234,7 +1184,6 @@ export function createAutomationService({ summary?: string | null; queueItemId?: string | null; chatSessionId?: string | null; - missionId?: string | null; workerRunId?: string | null; workerAgentId?: string | null; ingressEventId?: string | null; @@ -1253,7 +1202,6 @@ export function createAutomationService({ project_id, automation_id, chat_session_id, - mission_id, worker_run_id, worker_agent_id, queue_item_id, @@ -1274,14 +1222,13 @@ export function createAutomationService({ summary, confidence_json, billing_code - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?) `, [ runId, projectId, args.rule.id, args.chatSessionId ?? null, - args.missionId ?? null, args.workerRunId ?? null, args.workerAgentId ?? null, args.queueItemId ?? null, @@ -1306,7 +1253,6 @@ export function createAutomationService({ id: runId, automation_id: args.rule.id, chat_session_id: args.chatSessionId ?? null, - mission_id: args.missionId ?? null, worker_run_id: args.workerRunId ?? null, worker_agent_id: args.workerAgentId ?? null, queue_item_id: args.queueItemId ?? null, @@ -1394,7 +1340,7 @@ export function createAutomationService({ const loadRunRow = (runId: string): AutomationRunRow | null => db.get( ` select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, + id, automation_id, chat_session_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, trigger_metadata, summary, confidence_json, billing_code from automation_runs @@ -1407,7 +1353,7 @@ export function createAutomationService({ const loadQueueItemRow = (queueItemId: string): AutomationQueueItemRow | null => db.get( ` select - id, automation_id, run_id, mission_id, position, title, mode, queue_status, trigger_type, summary, severity_summary, + id, automation_id, run_id, position, title, mode, queue_status, trigger_type, summary, severity_summary, confidence_json, file_count, spend_usd, verification_required, suggested_actions_json, created_at, updated_at from automation_queue_items @@ -1489,7 +1435,6 @@ export function createAutomationService({ id?: string | null; rule: AutomationRule; runId: string | null; - missionId: string | null; queueStatus: AutomationRunQueueStatus; triggerType: AutomationTriggerType; summary: string | null; @@ -1506,13 +1451,12 @@ export function createAutomationService({ db.run( ` update automation_queue_items - set run_id = ?, mission_id = ?, queue_status = ?, summary = ?, severity_summary = ?, confidence_json = ?, + set run_id = ?, queue_status = ?, summary = ?, severity_summary = ?, confidence_json = ?, spend_usd = ?, verification_required = ?, updated_at = ? where id = ? and project_id = ? `, [ args.runId, - args.missionId, args.queueStatus, args.summary, args.severitySummary ?? null, @@ -1528,17 +1472,16 @@ export function createAutomationService({ db.run( ` insert into automation_queue_items( - id, project_id, automation_id, run_id, mission_id, position, title, mode, queue_status, trigger_type, summary, + id, project_id, automation_id, run_id, position, title, mode, queue_status, trigger_type, summary, severity_summary, confidence_json, file_count, spend_usd, verification_required, suggested_actions_json, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) `, [ queueItemId, projectId, args.rule.id, args.runId, - args.missionId, getNextNightShiftPosition(), title, args.rule.mode, @@ -1565,7 +1508,7 @@ export function createAutomationService({ ); }; - const buildMissionPrompt = (args: { + const buildAutomationPrompt = (args: { rule: AutomationRule; trigger: TriggerContext; executionLaneId?: string | null; @@ -1806,7 +1749,7 @@ export function createAutomationService({ if (action.type === "agent-session") { // Spawn a scoped agent chat session as one step in a built-in chain. // The prompt on the action wins over the rule-level prompt; placeholders - // inside the prompt are substituted the same way buildMissionPrompt does. + // inside the prompt are substituted the same way buildAutomationPrompt does. if (!agentChatServiceRef) { return { status: "failed", output: "Agent chat service is unavailable." }; } @@ -1829,7 +1772,7 @@ export function createAutomationService({ const permissionMode = resolveProviderPermissionMode(providerGroup, permissionConfig); const reasoningEffort = action.modelConfig?.thinkingLevel ?? rule.execution?.session?.reasoningEffort - ?? rule.modelConfig?.orchestratorModel?.thinkingLevel + ?? rule.modelConfig?.thinkingLevel ?? null; const timeoutMs = Math.max( 15_000, @@ -1869,36 +1812,6 @@ export function createAutomationService({ return { status: "failed", output: err instanceof Error ? err.message : String(err) }; } } - if (action.type === "launch-mission") { - const laneMode = rule.execution?.laneMode; - const actionTargetLaneId = trimToNull(action.targetLaneId); - const ruleTargetLaneId = trimToNull(rule.execution?.targetLaneId); - const missionRule: AutomationRule = { - ...rule, - execution: { - kind: "mission", - ...(laneMode ? { laneMode } : {}), - ...(laneMode === "create" && rule.execution?.laneNamePreset ? { laneNamePreset: rule.execution.laneNamePreset } : {}), - ...(laneMode === "create" && rule.execution?.laneNameTemplate ? { laneNameTemplate: rule.execution.laneNameTemplate } : {}), - ...(laneMode !== "require-on-trigger" && (actionTargetLaneId ?? ruleTargetLaneId) - ? { targetLaneId: actionTargetLaneId ?? ruleTargetLaneId } - : {}), - mission: { title: action.sessionTitle?.trim() || rule.execution?.mission?.title || null }, - }, - }; - const missionRun = await dispatchMissionRun({ - rule: missionRule, - trigger, - existingRunId: runId, - recordDispatchAction: false, - }); - return { - status: "succeeded", - output: missionRun.missionId - ? `Mission ${missionRun.missionId} started for automation dispatch.` - : "Mission started for automation dispatch.", - }; - } if (action.type === "run-command") { const command = (action.command ?? "").trim(); if (!command) throw new Error("run-command requires command"); @@ -1949,7 +1862,6 @@ export function createAutomationService({ let runStatus: AutomationRunStatus = "succeeded"; let runError: string | null = null; let finalQueueStatus: AutomationRunQueueStatus = "pending-review"; - let keepRunOpen = false; try { for (let index = 0; index < actions.length; index += 1) { const action = actions[index]!; @@ -1963,10 +1875,6 @@ export function createAutomationService({ lastOutput = result.output ?? null; if (result.status === "failed") throw new Error(result.output ?? "Action failed"); finishAction({ id: actionId, status: result.status, output: result.output ?? null }); - if (action.type === "launch-mission") { - runStatus = "running"; - keepRunOpen = true; - } break; } catch (error) { if (attempt >= maxRetry) throw error; @@ -1994,7 +1902,7 @@ export function createAutomationService({ summary: runError, }); updateRun(run.id, { - ended_at: keepRunOpen ? null : nowIso(), + ended_at: nowIso(), status: runStatus, error_message: runError, queue_status: finalQueueStatus, @@ -2005,7 +1913,6 @@ export function createAutomationService({ id: run.id, automation_id: rule.id, chat_session_id: null, - mission_id: null, worker_run_id: null, worker_agent_id: null, queue_item_id: null, @@ -2145,7 +2052,7 @@ export function createAutomationService({ }; const resolveAutomationModelDescriptor = (rule: AutomationRule, action?: AutomationAction | null) => { - const requestedModelId = action?.modelConfig?.modelId ?? rule.modelConfig?.orchestratorModel?.modelId; + const requestedModelId = action?.modelConfig?.modelId ?? rule.modelConfig?.modelId; if (requestedModelId && !getModelById(requestedModelId)) { throw new Error(`Unknown model '${requestedModelId}'.`); } @@ -2211,7 +2118,7 @@ export function createAutomationService({ emit({ type: "runs-updated", automationId: args.rule.id, runId: run.id }); throw new Error(message); } - const prompt = buildMissionPrompt({ rule: args.rule, trigger: args.trigger, executionLaneId: laneId }); + const prompt = buildAutomationPrompt({ rule: args.rule, trigger: args.trigger, executionLaneId: laneId }); const actionId = insertAction(run.id, 0, "agent-session"); const permissionConfig = buildPermissionConfig(args.rule, { publishPhase: false }); @@ -2220,7 +2127,7 @@ export function createAutomationService({ const permissionMode = verificationRequired || dryRun ? "plan" : resolveProviderPermissionMode(providerGroup, permissionConfig); - const reasoningEffort = args.rule.execution?.session?.reasoningEffort ?? args.rule.modelConfig?.orchestratorModel?.thinkingLevel ?? null; + const reasoningEffort = args.rule.execution?.session?.reasoningEffort ?? args.rule.modelConfig?.thinkingLevel ?? null; const codexFastMode = providerGroup === "codex" && args.rule.execution?.session?.codexFastMode === true && modelSupportsFastMode(modelDescriptor); @@ -2316,7 +2223,6 @@ export function createAutomationService({ id: run.id, automation_id: args.rule.id, chat_session_id: sessionId, - mission_id: null, worker_run_id: null, worker_agent_id: null, queue_item_id: null, @@ -2340,189 +2246,6 @@ export function createAutomationService({ }); }; - const dispatchMissionRun = async (args: { - rule: AutomationRule; - trigger: TriggerContext; - existingRunId?: string | null; - existingQueueItemId?: string | null; - queuedFromNightShift?: boolean; - publishPhase?: boolean; - recordDispatchAction?: boolean; - }): Promise => { - if (!missionServiceRef || !aiOrchestratorServiceRef) { - throw new Error("Mission automation services are unavailable"); - } - const confidence = computeConfidence(args.rule); - const permissionConfig = buildPermissionConfig(args.rule, { publishPhase: Boolean(args.publishPhase) }); - const budgetScope = AUTOMATION_SCOPE; - const { budgetProvider } = resolveAutomationModelDescriptor(args.rule); - const budgetCheck = budgetCapServiceRef?.checkBudget( - budgetScope as Parameters["checkBudget"]>[0], - args.rule.id, - budgetProvider, - ); - if (budgetCheck && !budgetCheck.allowed) { - throw new Error(budgetCheck.reason ?? "Budget cap blocked automation run."); - } - - const existingRunRowEarly = args.existingRunId ? loadRunRow(args.existingRunId) : null; - const earlyRun = existingRunRowEarly - ? toRun(existingRunRowEarly) - : insertRun({ - rule: args.rule, - trigger: args.trigger, - actionsTotal: 1, - queueStatus: "pending-review", - confidence, - summary: args.rule.prompt?.trim() || `${args.rule.mode} automation dispatched`, - queueItemId: args.existingQueueItemId ?? null, - ingressEventId: args.trigger.ingressEventId ?? null, - }); - - let laneId: string | null; - try { - laneId = await resolveExecutionLaneId(args.rule, args.trigger, null, earlyRun.id); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - updateRun(earlyRun.id, { ended_at: nowIso(), status: "failed", error_message: message }); - emit({ type: "runs-updated", automationId: args.rule.id, runId: earlyRun.id }); - throw error; - } - const prompt = buildMissionPrompt({ rule: args.rule, trigger: args.trigger, executionLaneId: laneId }); - const mission = missionServiceRef.create({ - title: `${args.rule.name} · ${args.rule.mode}`, - prompt, - laneId, - autostart: false, - launchMode: "autopilot", - autopilotExecutor: "opencode", - priority: args.rule.mode === "fix" ? "high" : "normal", - employeeAgentId: null, - ...(args.rule.modelConfig ? { modelConfig: args.rule.modelConfig } : {}), - permissionConfig, - }); - - missionServiceRef.patchMetadata(mission.id, { - automation: { - ruleId: args.rule.id, - mode: args.rule.mode, - reviewProfile: args.trigger.reviewProfileOverride ?? args.rule.reviewProfile, - executorMode: "automation-bot", - billingCode: args.rule.billingCode, - trigger: buildTriggerMetadata(args.trigger), - outputs: args.rule.outputs, - verification: args.rule.verification, - toolPalette: args.rule.toolPalette, - allowedTools: computeAllowedToolList(args.rule, { publishPhase: Boolean(args.publishPhase) }), - contextSources: args.rule.contextSources, - confidence, - publishPhase: Boolean(args.publishPhase), - }, - launch: { - automation: { - ruleId: args.rule.id, - executorMode: "automation-bot", - reviewProfile: args.trigger.reviewProfileOverride ?? args.rule.reviewProfile, - verboseTrace: Boolean(args.trigger.verboseTrace), - queueStatus: "pending-review", - } - } - }); - - const run = earlyRun; - updateRun(run.id, { mission_id: mission.id }); - - if (args.existingRunId) { - updateRun(args.existingRunId, { - mission_id: mission.id, - status: "running", - queue_status: "pending-review", - summary: args.rule.prompt?.trim() || `${args.rule.mode} automation dispatched`, - confidence_json: JSON.stringify(confidence), - verification_required: requiresPublishGate(args.rule) && !args.publishPhase ? 1 : 0, - billing_code: args.rule.billingCode, - }); - } - - if (args.recordDispatchAction !== false) { - const dispatchActionId = insertAction(run.id, 0, "launch-mission"); - finishAction({ - id: dispatchActionId, - status: "succeeded", - output: `Mission ${mission.id} started for automation dispatch.`, - }); - } - updateRun(run.id, { - actions_completed: 1, - mission_id: mission.id, - queue_item_id: args.existingQueueItemId ?? null, - }); - - const queueItemId = upsertQueueItem({ - id: args.existingQueueItemId ?? null, - rule: args.rule, - runId: run.id, - missionId: mission.id, - queueStatus: "pending-review", - triggerType: args.trigger.triggerType, - summary: args.rule.prompt?.trim() || `${args.rule.mode} automation dispatched`, - severitySummary: args.rule.mode === "review" ? "Awaiting review output" : "Awaiting mission completion", - confidence, - spendUsd: 0, - verificationRequired: requiresPublishGate(args.rule) && !args.publishPhase, - }); - updateRun(run.id, { queue_item_id: queueItemId }); - - if (requiresPublishGate(args.rule) && !args.publishPhase) { - upsertPendingPublish({ - runId: run.id, - automationId: args.rule.id, - queueItemId, - summary: args.trigger.summary?.trim() || `Review ${args.rule.name} before enabling publish-capable tools.`, - toolPalette: args.rule.toolPalette.filter((family) => PUBLISH_CAPABLE_TOOL_FAMILIES.has(family)), - }); - updateQueueItemStatus(queueItemId, "verification-required"); - updateRun(run.id, { queue_status: "verification-required" }); - } else if (args.publishPhase) { - resolvePendingPublish(run.id); - } - - await aiOrchestratorServiceRef.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - autopilotOwnerId: "automation-bot", - }); - - emit({ type: "runs-updated", automationId: args.rule.id, runId: run.id }); - return toRun(loadRunRow(run.id) ?? { - id: run.id, - automation_id: args.rule.id, - chat_session_id: null, - mission_id: mission.id, - worker_run_id: null, - worker_agent_id: null, - queue_item_id: queueItemId, - ingress_event_id: args.trigger.ingressEventId ?? null, - trigger_type: args.trigger.triggerType, - started_at: run.startedAt, - ended_at: null, - status: "running", - execution_kind: resolveExecutionKind(args.rule), - queue_status: "pending-review", - executor_mode: args.rule.executor.mode, - actions_completed: 1, - actions_total: 1, - error_message: null, - verification_required: requiresPublishGate(args.rule) && !args.publishPhase ? 1 : 0, - spend_usd: 0, - trigger_metadata: JSON.stringify(buildTriggerMetadata(args.trigger)), - summary: args.rule.prompt?.trim() || `${args.rule.mode} automation dispatched`, - confidence_json: JSON.stringify(confidence), - billing_code: args.rule.billingCode, - }); - }; - const simulateDryRun = async (rule: AutomationRule, trigger: TriggerContext): Promise => { const confidence = computeConfidence(rule); const summary = `${rule.prompt?.trim() || rule.name} (dry run)`; @@ -2556,7 +2279,6 @@ export function createAutomationService({ id: run.id, automation_id: rule.id, chat_session_id: null, - mission_id: null, worker_run_id: null, worker_agent_id: null, queue_item_id: null, @@ -2591,7 +2313,7 @@ export function createAutomationService({ if (inFlightByAutomationId.has(rule.id)) { const existing = db.get( `select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, + id, automation_id, chat_session_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, trigger_metadata, summary, confidence_json, billing_code from automation_runs @@ -2610,7 +2332,7 @@ export function createAutomationService({ const executionKind = resolveExecutionKind(rule); if (executionKind === "agent-session") return await dispatchAgentSessionRun({ rule, trigger }); if (executionKind === "built-in") return await runLegacyRule(rule, trigger); - return await dispatchMissionRun({ rule, trigger }); + throw new Error(`Unsupported automation execution kind: ${executionKind}`); } finally { inFlightByAutomationId.delete(rule.id); } @@ -2778,88 +2500,12 @@ export function createAutomationService({ }); }; - const syncMissionRun = async (missionId: string) => { - if (!missionServiceRef) return; - const runRow = db.get( - ` - select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, - executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, - trigger_metadata, summary, confidence_json, billing_code - from automation_runs - where project_id = ? and mission_id = ? - order by started_at desc - limit 1 - `, - [projectId, missionId] - ); - if (!runRow) return; - const mission = missionServiceRef.get(missionId); - if (!mission) return; - const verificationRequired = Boolean(runRow.verification_required ?? 0); - const nextStatus = mapMissionStatus(mission.status, verificationRequired); - const nextSummary = mission.outcomeSummary?.trim() || runRow.summary || null; - const rule = findRule(runRow.automation_id); - const nextQueueStatus = deriveQueueStatus({ - current: normalizeQueueStatus(runRow.queue_status, "pending-review"), - runStatus: nextStatus, - verificationRequired, - mode: rule?.mode ?? "review", - summary: nextSummary, - }); - if ( - nextStatus !== normalizeRunStatus(runRow.status, "running") || - nextQueueStatus !== normalizeQueueStatus(runRow.queue_status, "pending-review") || - nextSummary !== (runRow.summary ?? null) - ) { - updateRun(runRow.id, { - status: nextStatus, - queue_status: nextQueueStatus, - summary: nextSummary, - ended_at: nextStatus === "running" || nextStatus === "queued" ? null : (mission.completedAt ?? nowIso()), - error_message: mission.lastError ?? runRow.error_message, - }); - if (runRow.queue_item_id) { - upsertQueueItem({ - id: runRow.queue_item_id, - rule: rule ?? { - id: runRow.automation_id, - name: runRow.automation_id, - enabled: true, - mode: "review", - triggers: [{ type: runRow.trigger_type as AutomationTriggerType }], - trigger: { type: runRow.trigger_type as AutomationTriggerType }, - executor: { mode: "automation-bot" }, - reviewProfile: "quick", - toolPalette: ["repo", "mission"], - contextSources: [], - guardrails: {}, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: verificationRequired, mode: "intervention" }, - billingCode: `auto:${runRow.automation_id}`, - actions: [], - }, - runId: runRow.id, - missionId, - queueStatus: nextQueueStatus, - triggerType: runRow.trigger_type as AutomationTriggerType, - summary: nextSummary, - severitySummary: mission.lastError ? mission.lastError : nextStatus === "succeeded" ? "Mission completed" : "Mission paused", - confidence: normalizeConfidence(safeJsonParse(runRow.confidence_json ?? "null", null)), - spendUsd: Number(runRow.spend_usd ?? 0), - verificationRequired, - }); - } - emit({ type: "runs-updated", automationId: runRow.automation_id, runId: runRow.id }); - } - }; - const syncWorkerRun = async (workerRunId: string) => { if (!workerHeartbeatServiceRef) return; const runRow = db.get( ` select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, + id, automation_id, chat_session_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, trigger_metadata, summary, confidence_json, billing_code from automation_runs @@ -2908,7 +2554,7 @@ export function createAutomationService({ trigger: { type: runRow.trigger_type as AutomationTriggerType }, executor: { mode: "automation-bot" }, reviewProfile: "quick", - toolPalette: ["repo", "mission"], + toolPalette: ["repo"], contextSources: [], guardrails: {}, outputs: { disposition: "comment-only", createArtifact: true }, @@ -2917,7 +2563,6 @@ export function createAutomationService({ actions: [], }, runId: runRow.id, - missionId: null, queueStatus: nextQueueStatus, triggerType: runRow.trigger_type as AutomationTriggerType, summary: nextSummary, @@ -3087,14 +2732,10 @@ export function createAutomationService({ return { syncFromConfig, - bindMissionRuntime(args: { - missionService?: ReturnType; - aiOrchestratorService?: ReturnType; + bindRuntime(args: { budgetCapService?: ReturnType; workerHeartbeatService?: ReturnType | null; }) { - missionServiceRef = args.missionService ?? missionServiceRef; - aiOrchestratorServiceRef = args.aiOrchestratorService ?? aiOrchestratorServiceRef; budgetCapServiceRef = args.budgetCapService ?? budgetCapServiceRef; workerHeartbeatServiceRef = args.workerHeartbeatService ?? workerHeartbeatServiceRef; }, @@ -3108,12 +2749,6 @@ export function createAutomationService({ const snapshot = projectConfigService.get(); const localRuleIds = new Set((snapshot.local?.automations ?? []).map((rule) => rule?.id).filter((id): id is string => typeof id === "string" && id.trim().length > 0)); const sharedRuleIds = new Set((snapshot.shared?.automations ?? []).map((rule) => rule?.id).filter((id): id is string => typeof id === "string" && id.trim().length > 0)); - for (const row of db.all<{ mission_id: string | null }>( - `select distinct mission_id from automation_runs where project_id = ? and mission_id is not null`, - [projectId] - )) { - if (row.mission_id) void syncMissionRun(row.mission_id); - } for (const row of db.all<{ worker_run_id: string | null }>( `select distinct worker_run_id from automation_runs where project_id = ? and worker_run_id is not null`, [projectId] @@ -3124,7 +2759,7 @@ export function createAutomationService({ const runRow = db.get( ` select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, + id, automation_id, chat_session_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, trigger_metadata, summary, confidence_json, billing_code from automation_runs @@ -3229,7 +2864,7 @@ export function createAutomationService({ const rows = db.all( ` select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, + id, automation_id, chat_session_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, trigger_metadata, summary, confidence_json, billing_code from automation_runs @@ -3240,7 +2875,6 @@ export function createAutomationService({ [projectId, id, limit] ); for (const row of rows) { - if (row.mission_id) void syncMissionRun(row.mission_id); if (row.worker_run_id) void syncWorkerRun(row.worker_run_id); } return rows.map((row) => toRun(loadRunRow(row.id) ?? row)); @@ -3262,7 +2896,7 @@ export function createAutomationService({ const rows = db.all( ` select - id, automation_id, chat_session_id, mission_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, + id, automation_id, chat_session_id, worker_run_id, worker_agent_id, queue_item_id, ingress_event_id, trigger_type, started_at, ended_at, status, execution_kind, queue_status, executor_mode, actions_completed, actions_total, error_message, verification_required, spend_usd, trigger_metadata, summary, confidence_json, billing_code from automation_runs @@ -3280,7 +2914,6 @@ export function createAutomationService({ if (!runId) return null; const runRow = loadRunRow(runId); if (!runRow) return null; - if (runRow.mission_id) void syncMissionRun(runRow.mission_id); if (runRow.worker_run_id) void syncWorkerRun(runRow.worker_run_id); const refreshed = loadRunRow(runId) ?? runRow; const actions = db.all( @@ -3524,11 +3157,6 @@ export function createAutomationService({ } }, - onMissionUpdated(args: { missionId: string }) { - if (!args.missionId.trim()) return; - void syncMissionRun(args.missionId.trim()); - }, - onWorkerRunUpdated(args: { workerRunId: string }) { if (!args.workerRunId.trim()) return; void syncWorkerRun(args.workerRunId.trim()); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index ee6fbb08c..8c6bde8fd 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -514,7 +514,7 @@ vi.mock("../orchestrator/providerOrchestratorAdapter", () => ({ resolveOpenCodeRuntimeRoot: vi.fn(() => process.cwd()), })); -vi.mock("../orchestrator/permissionMapping", () => ({ +vi.mock("./permissionMapping", () => ({ mapPermissionToClaude: vi.fn(() => "plan"), mapPermissionToCodex: vi.fn(() => ({ approvalPolicy: "on-request", @@ -675,7 +675,7 @@ import { detectAllAuth } from "../ai/authDetector"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { runGit } from "../git/git"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import { mapPermissionToCodex } from "../orchestrator/permissionMapping"; +import { mapPermissionToCodex } from "./permissionMapping"; import { acquireCursorSdkConnection } from "./cursorSdkPool"; import { acquireDroidSdkConnection } from "./droidSdkPool"; import type { AgentChatEventEnvelope, ComputerUseBackendStatus, LaneLinearIssue, PendingInputRequest } from "../../../shared/types"; @@ -2685,58 +2685,6 @@ describe("createAgentChatService", () => { expect(startParams?.reasoning_effort).toBeUndefined(); }); - it("starts mission Codex app-server sessions without global MCP servers", async () => { - const laneRootPath = path.join(tmpRoot, "lane-2"); - fs.mkdirSync(laneRootPath, { recursive: true }); - - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-2", - provider: "codex", - model: "gpt-5.4", - reasoningEffort: "low", - surface: "mission", - }); - - await service.sendMessage({ - sessionId: session.id, - text: "Plan this mission without loading desktop MCP helpers.", - }); - - await vi.waitFor(() => { - expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); - }); - - expect(spawn).toHaveBeenCalledWith( - "codex", - [ - "app-server", - "-c", - "model_reasoning_effort=\"low\"", - "-c", - "mcp_servers={}", - ], - expect.any(Object), - ); - const spawnCall = vi.mocked(spawn).mock.calls.find((call) => - call[0] === "codex" && Array.isArray(call[1]) && call[1].includes("app-server") - ); - const spawnArgs = spawnCall?.[1] as string[] | undefined; - expect(spawnArgs).toBeDefined(); - expect(spawnArgs).not.toContain("--disable"); - expect(spawnArgs).not.toContain("browser_use"); - expect(spawnArgs).not.toContain("computer_use"); - const spawnOptions = spawnCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined; - const codexHome = spawnOptions?.env?.CODEX_HOME; - expect(codexHome).toContain("ade-mission-codex-home"); - const configToml = fs.readFileSync(path.join(String(codexHome), "config.toml"), "utf8"); - expect(configToml).toContain("enable_mcp_apps = false"); - expect(configToml).toContain("multi_agent = false"); - expect(configToml).toContain("plugins = false"); - expect(configToml).toContain("apps = false"); - expect(configToml).not.toContain("[mcp_servers"); - }); - it("spawns Codex with ADE CLI agent env injected", async () => { const laneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(laneRootPath, { recursive: true }); @@ -5138,46 +5086,6 @@ describe("createAgentChatService", () => { expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); }); - it("keeps mission-surface sessions on deterministic titles and summaries", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription", cli: "claude", authenticated: true }, - { type: "cli-subscription", cli: "codex", authenticated: true }, - ] as any); - const { service, aiIntegrationService } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "codex", - model: "gpt-5.5", - surface: "mission", - }); - - await service.sendMessage({ - sessionId: session.id, - text: "Execute worker step \"Run targeted throwaway score tests\".", - }, { awaitDispatch: true }); - mockState.emitCodexPayload({ - jsonrpc: "2.0", - method: "item/completed", - params: { - turnId: "turn-1", - item: { - id: "assistant-1", - type: "assistant_message", - text: "Both targeted tests passed.", - }, - }, - }); - mockState.emitCodexPayload({ - jsonrpc: "2.0", - method: "turn/completed", - params: { turnId: "turn-1" }, - }); - - await service.dispose({ sessionId: session.id }); - - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); - }); - describe("runtime-native chat titles", () => { it("adopts Codex app-server thread names from lifecycle responses", async () => { mockState.codexResponseOverrides.set("thread/start", () => ({ @@ -7013,7 +6921,7 @@ describe("createAgentChatService", () => { const firstTurn = service.runSessionTurn({ sessionId: session.id, - text: "Start mission coordination.", + text: "Start coordination.", }); await vi.waitFor(() => { expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(1); @@ -7046,7 +6954,7 @@ describe("createAgentChatService", () => { const secondTurn = service.runSessionTurn({ sessionId: session.id, - text: "Continue mission coordination.", + text: "Continue coordination.", }); await vi.waitFor(() => { expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2); @@ -7157,7 +7065,7 @@ describe("createAgentChatService", () => { const firstTurn = service.runSessionTurn({ sessionId: session.id, - text: "Start mission coordination.", + text: "Start coordination.", }); await vi.waitFor(() => { expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); @@ -7221,7 +7129,7 @@ describe("createAgentChatService", () => { const firstTurn = service.runSessionTurn({ sessionId: session.id, - text: "Start mission coordination.", + text: "Start coordination.", }); await vi.waitFor(() => { expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(1); @@ -7253,7 +7161,7 @@ describe("createAgentChatService", () => { const secondTurn = service.runSessionTurn({ sessionId: session.id, - text: "Continue mission coordination.", + text: "Continue coordination.", }); await vi.waitFor(() => { expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2); @@ -9441,42 +9349,6 @@ describe("createAgentChatService", () => { ); }); - it("keeps mission-surface Codex coordinator sessions out of native plan collaboration", async () => { - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "codex", - model: "gpt-5.5", - codexApprovalPolicy: "on-request", - codexSandbox: "read-only", - codexConfigSource: "flags", - surface: "mission", - }); - expect(session.permissionMode).toBe("plan"); - expect(session.surface).toBe("mission"); - - await service.sendMessage({ - sessionId: session.id, - text: "Coordinate the mission without provider-native plan approval.", - }); - - await vi.waitFor(() => { - expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); - }); - const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); - const params = turnStartRequest?.params as { - approvalPolicy?: unknown; - sandboxPolicy?: { type?: unknown }; - collaborationMode?: { mode?: unknown }; - } | undefined; - - expect(params?.approvalPolicy).toBe("on-request"); - expect(params?.sandboxPolicy?.type).toBe("readOnly"); - expect(params?.collaborationMode?.mode).toBe("default"); - expect(await service.listSessions()).toHaveLength(0); - expect(await service.listSessions(undefined, { includeAutomation: true })).toHaveLength(1); - }); - it("turns native Codex plan items into an implementation approval request", async () => { vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 3d41460db..7a841561e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -316,8 +316,6 @@ import { promises as fsPromises } from "node:fs"; import { mapStopReasonToTerminalEvents } from "./stopReasonEvents"; import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { getApiKey } from "../ai/apiKeyStore"; -import type { createMissionService } from "../missions/missionService"; -import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; const CLAUDE_AGENT_SDK_VERSION = "0.2.139"; @@ -3129,7 +3127,7 @@ function buildExecutionModeDirective( if (provider === "droid" && (mode === "parallel" || mode === "subagents" || mode === "teams")) { return [ "[ADE launch directive]", - "Use Droid's available delegation or mission-style tools for independent subtasks when they will materially improve latency or coverage.", + "Use Droid's available delegation tools for independent subtasks when they will materially improve latency or coverage.", "Split bounded work into narrowly scoped delegates, let them complete independently, then reconcile the results before the final answer.", "If the task is tightly coupled, stay focused instead of forcing delegation.", ].join("\n"); @@ -3288,12 +3286,10 @@ function activityForToolName( return { activity: "tool_calling", detail: normalized }; } -// Permission mapping functions are shared with the orchestrator/mission system. -// Delegate to the single source of truth in permissionMapping.ts. import { mapPermissionToClaude, mapPermissionToCodex -} from "../orchestrator/permissionMapping"; +} from "./permissionMapping"; function codexSandboxPolicyType(sandbox: AgentChatCodexSandbox): string { switch (sandbox) { @@ -3827,18 +3823,15 @@ function buildCodexDeveloperInstructions(args: { } function resolveCodexInstructionCollaborationMode( - session: Pick, + session: Pick, ): "default" | "plan" { - return (session.interactionMode === "plan" || session.permissionMode === "plan") - && session.surface !== "mission" - ? "plan" - : "default"; + return (session.interactionMode === "plan" || session.permissionMode === "plan") ? "plan" : "default"; } function buildCodexCollaborationMode( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" | "surface" + "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" >, supportedModes: Set | null, laneWorktreePath: string, @@ -3872,7 +3865,7 @@ function buildCodexCollaborationMode( function resolveRequestedCodexCollaborationMode( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "interactionMode" | "codexConfigSource" | "surface" + "provider" | "permissionMode" | "interactionMode" | "codexConfigSource" >, ): "default" | "plan" | null { if (session.provider !== "codex") return null; @@ -4303,8 +4296,6 @@ export function createAgentChatService(args: { workerHeartbeatService?: ReturnType | null; linearIssueTracker?: IssueTracker | null; flowPolicyService?: ReturnType | null; - getMissionService?: () => ReturnType | null; - getAiOrchestratorService?: () => ReturnType | null; getLinearDispatcherService?: () => ReturnType | null; linearClient?: LinearClient | null; linearCredentials?: LinearCredentialService | null; @@ -4317,7 +4308,6 @@ export function createAgentChatService(args: { getGitService?: () => CtoOperatorToolDeps["gitService"]; conflictService?: CtoOperatorToolDeps["conflictService"]; getWorkerBudgetService?: () => CtoOperatorToolDeps["workerBudgetService"]; - getMissionBudgetService?: () => CtoOperatorToolDeps["missionBudgetService"]; computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; laneService: ReturnType; sessionService: ReturnType; @@ -4341,8 +4331,6 @@ export function createAgentChatService(args: { workerHeartbeatService, linearIssueTracker, flowPolicyService, - getMissionService, - getAiOrchestratorService, getLinearDispatcherService, linearClient: linearClientRef, linearCredentials: linearCredentialsRef, @@ -4355,7 +4343,6 @@ export function createAgentChatService(args: { getGitService, conflictService, getWorkerBudgetService, - getMissionBudgetService, computerUseArtifactBrokerService, laneService, sessionService, @@ -4387,48 +4374,6 @@ export function createAgentChatService(args: { ADE_WORKSPACE_ROOT: managed.laneWorktreePath, }); - const tomlString = (value: string): string => JSON.stringify(value); - - const ensureMissionCodexHome = (managed: ManagedChatSession): string => { - const safeSessionId = managed.session.id.replace(/[^a-zA-Z0-9_.-]/g, "_"); - const codexHome = path.join(os.tmpdir(), "ade-mission-codex-home", safeSessionId); - fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); - - const sourceAuthPath = path.join(os.homedir(), ".codex", "auth.json"); - const targetAuthPath = path.join(codexHome, "auth.json"); - if (fs.existsSync(sourceAuthPath) && !fs.existsSync(targetAuthPath)) { - try { - fs.symlinkSync(sourceAuthPath, targetAuthPath); - } catch { - // If symlinks are unavailable, leave auth resolution to other Codex mechanisms. - } - } - - const reasoningEffort = managed.session.reasoningEffort ?? "medium"; - const configToml = [ - `model = ${tomlString(managed.session.model || "gpt-5.5")}`, - `model_reasoning_effort = ${tomlString(reasoningEffort)}`, - `sandbox_mode = "danger-full-access"`, - `approval_policy = "never"`, - ``, - `[projects.${tomlString(managed.laneWorktreePath)}]`, - `trust_level = "trusted"`, - ``, - `[features]`, - `apps = false`, - `browser_use = false`, - `computer_use = false`, - `multi_agent = false`, - `enable_mcp_apps = false`, - `plugins = false`, - `tool_search_always_defer_mcp_tools = false`, - ``, - `[plugins]`, - ].join("\n"); - fs.writeFileSync(path.join(codexHome, "config.toml"), configToml, { mode: 0o600 }); - return codexHome; - }; - const eventSubscribers = new Set<(event: AgentChatEventEnvelope) => void>(); // In-memory ring buffer of recent chat events per session. Populated on every @@ -5209,8 +5154,6 @@ export function createAgentChatService(args: { defaultReasoningEffort: null, resolveExecutionLane: async ({ requestedLaneId }) => requestedLaneId?.trim() || laneId, laneService, - missionService: getMissionService?.() ?? null, - aiOrchestratorService: getAiOrchestratorService?.() ?? null, workerAgentService: workerAgentService ?? null, workerHeartbeatService: workerHeartbeatService ?? null, linearDispatcherService: getLinearDispatcherService?.() ?? null, @@ -5226,7 +5169,6 @@ export function createAgentChatService(args: { conflictService: conflictService ?? null, computerUseArtifactBrokerService: computerUseArtifactBrokerRef ?? null, workerBudgetService: getWorkerBudgetService?.() ?? null, - missionBudgetService: getMissionBudgetService?.() ?? null, steerChat: undefined, cancelSteer: undefined, handoffChat: undefined, @@ -5458,7 +5400,6 @@ export function createAgentChatService(args: { sessionId: string; threadId: string; laneWorktreePath: string; - isMission: boolean; provider: AgentChatProvider; } | null => { const managed = managedSessions.get(sessionId); @@ -5470,7 +5411,6 @@ export function createAgentChatService(args: { sessionId, threadId, laneWorktreePath, - isMission: session.surface === "mission", provider: session.provider, }; }; @@ -6254,7 +6194,6 @@ export function createAgentChatService(args: { args: { stage: "initial" | "final"; latestUserText?: string | null; summary?: string | null } ): Promise => { if (managed.deleted) return; - if (managed.session.surface === "mission") return; const config = resolveChatConfig(); if (!config.titleGenerationEnabled) return; if (sessionIsManuallyNamed(managed)) return; @@ -7172,7 +7111,7 @@ export function createAgentChatService(args: { : undefined; const cursorConfigValues = normalizeCursorConfigValueRecord(record.cursorConfigValues); const identityKey = normalizeIdentityKey(record.identityKey); - const surface = record.surface === "automation" || record.surface === "mission" ? record.surface : "work"; + const surface = record.surface === "automation" ? record.surface : "work"; const capabilityMode = normalizeCapabilityMode(record.capabilityMode); const completion = normalizePersistedCompletion(record.completion); if (!laneId || !model) return null; @@ -8200,8 +8139,6 @@ export function createAgentChatService(args: { sessionService.setSummary(managed.session.id, deterministicText); } - if (managed.session.surface === "mission") return; - // Fire-and-forget AI summary enhancement const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); @@ -13111,9 +13048,6 @@ export function createAgentChatService(args: { const next = `${runtime.commandOutputByItemId.get(itemId) ?? ""}${delta}`; runtime.commandOutputByItemId.set(itemId, next); evictOldestEntries(runtime.commandOutputByItemId, MAX_SESSION_MAP_ENTRIES); - if (managed.session.surface === "mission") { - return; - } emitChatEvent(managed, { type: "activity", activity: "running_command", @@ -13139,9 +13073,6 @@ export function createAgentChatService(args: { const next = `${runtime.fileDeltaByItemId.get(itemId) ?? ""}${delta}`; runtime.fileDeltaByItemId.set(itemId, next); evictOldestEntries(runtime.fileDeltaByItemId, MAX_SESSION_MAP_ENTRIES); - if (managed.session.surface === "mission") { - return; - } emitChatEvent(managed, { type: "activity", activity: "editing_file", @@ -13427,9 +13358,6 @@ export function createAgentChatService(args: { const next = `${runtime.planTextByItemId.get(itemId) ?? ""}${delta}`; runtime.planTextByItemId.set(itemId, next); evictOldestEntries(runtime.planTextByItemId, MAX_SESSION_MAP_ENTRIES); - if (managed.session.surface === "mission") { - return; - } emitChatEvent(managed, { type: "plan", steps: [], @@ -13510,7 +13438,6 @@ export function createAgentChatService(args: { }); throw error; } - const missionCodexHome = managed.session.surface === "mission" ? ensureMissionCodexHome(managed) : null; const appServerArgs = ["app-server"]; if (sessionSupportsReasoning(managed.session)) { const descriptor = resolveSessionModelDescriptor(managed.session); @@ -13522,13 +13449,10 @@ export function createAgentChatService(args: { managed.session.reasoningEffort = reasoningEffort; appServerArgs.push("-c", `model_reasoning_effort="${reasoningEffort}"`); } - if (missionCodexHome) { - appServerArgs.push("-c", "mcp_servers={}"); - } const invocation = resolveCliSpawnInvocation(codexExecutable, appServerArgs); const proc = spawn(invocation.command, invocation.args, { cwd: managed.laneWorktreePath, - env: missionCodexHome ? { ...spawnEnv, CODEX_HOME: missionCodexHome } : spawnEnv, + env: spawnEnv, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsVerbatimArguments: invocation.windowsVerbatimArguments, @@ -13697,9 +13621,6 @@ export function createAgentChatService(args: { const message = `Codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; const hadPendingRequests = pending.size > 0; const cleanExit = code === 0 && signal == null && !hadPendingRequests; - if (missionCodexHome) { - fs.rm(missionCodexHome, { recursive: true, force: true }, () => {}); - } if (runtime.killTimer) { clearTimeout(runtime.killTimer); runtime.killTimer = null; @@ -13749,26 +13670,22 @@ export function createAgentChatService(args: { } }); - if (managed.session.surface === "mission") { - runtime.collaborationModesReady = Promise.resolve(); - } else { - const collaborationModesRequest = runtime.request("collaborationMode/list", {}) - .then((res) => { - const modes = parseCodexCollaborationModes(res); - if (modes) { - runtime.collaborationModes = modes; - } - }) - .catch(() => { /* collaborationMode/list not supported — ignore */ }); - runtime.collaborationModesReady = Promise.race([ - collaborationModesRequest, - new Promise((resolve) => { - const timer = setTimeout(resolve, DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS); - timer.unref?.(); - collaborationModesRequest.finally(() => clearTimeout(timer)).catch(() => {}); - }), - ]).then(() => undefined); - } + const collaborationModesRequest = runtime.request("collaborationMode/list", {}) + .then((res) => { + const modes = parseCodexCollaborationModes(res); + if (modes) { + runtime.collaborationModes = modes; + } + }) + .catch(() => { /* collaborationMode/list not supported — ignore */ }); + runtime.collaborationModesReady = Promise.race([ + collaborationModesRequest, + new Promise((resolve) => { + const timer = setTimeout(resolve, DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS); + timer.unref?.(); + collaborationModesRequest.finally(() => clearTimeout(timer)).catch(() => {}); + }), + ]).then(() => undefined); runtime.notify("initialized"); return runtime; @@ -13861,31 +13778,29 @@ export function createAgentChatService(args: { runtime.canAttachResumedTurnStart = false; persistChatState(managed); - if (managed.session.surface !== "mission") { - // Fetch available skills and populate slash commands. - runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) - .then((res) => { - if (Array.isArray(res?.skills)) { - runtime.slashCommands = res.skills - .filter((s): s is { name: string; description?: string } => typeof s?.name === "string" && s.name.length > 0) - .map((s) => ({ name: s.name.startsWith("/") ? s.name : `/${s.name}`, description: s.description ?? "" })); - } - }) - .catch(() => { /* skills/list not supported — ignore */ }); - - // Fetch initial rate limits. - runtime.request<{ rateLimits?: { remaining?: number; limit?: number; resetAt?: string } }>("account/rateLimits/read", {}) - .then((res) => { - if (res?.rateLimits) { - runtime.rateLimits = { - remaining: typeof res.rateLimits.remaining === "number" ? res.rateLimits.remaining : null, - limit: typeof res.rateLimits.limit === "number" ? res.rateLimits.limit : null, - resetAt: typeof res.rateLimits.resetAt === "string" ? res.rateLimits.resetAt : null, - }; - } - }) - .catch(() => { /* account/rateLimits/read not supported — ignore */ }); - } + // Fetch available skills and populate slash commands. + runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) + .then((res) => { + if (Array.isArray(res?.skills)) { + runtime.slashCommands = res.skills + .filter((s): s is { name: string; description?: string } => typeof s?.name === "string" && s.name.length > 0) + .map((s) => ({ name: s.name.startsWith("/") ? s.name : `/${s.name}`, description: s.description ?? "" })); + } + }) + .catch(() => { /* skills/list not supported — ignore */ }); + + // Fetch initial rate limits. + runtime.request<{ rateLimits?: { remaining?: number; limit?: number; resetAt?: string } }>("account/rateLimits/read", {}) + .then((res) => { + if (res?.rateLimits) { + runtime.rateLimits = { + remaining: typeof res.rateLimits.remaining === "number" ? res.rateLimits.remaining : null, + limit: typeof res.rateLimits.limit === "number" ? res.rateLimits.limit : null, + resetAt: typeof res.rateLimits.resetAt === "string" ? res.rateLimits.resetAt : null, + }; + } + }) + .catch(() => { /* account/rateLimits/read not supported — ignore */ }); }; const stringifyClaudeToolOutput = (output: unknown): string => { @@ -22064,8 +21979,7 @@ export function createAgentChatService(args: { }; /** - * Create a blocking pending-input request for a chat session (used by ADE ask_user - * when no missionId is available). Returns the user's answer. + * Create a blocking pending-input request for a chat session. Returns the user's answer. */ const requestChatInput = async (args: { chatSessionId: string; diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 496843c57..3f44d40a1 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -40,7 +40,7 @@ export function parseDroidExecHelpModels(stdout: string): DroidExecHelpModelRow[ if (!inModelSection) continue; if (!trimmed.length) continue; if ( - /^(Model details|Authentication|Examples|Autonomy Levels|Mission Mode|Session Flags|Tool Controls):$/i.test(trimmed) + /^(Model details|Authentication|Examples|Autonomy Levels|Session Flags|Tool Controls):$/i.test(trimmed) || /^[-A-Z][\w -]+:$/i.test(trimmed) ) { break; diff --git a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts index 45e7621de..03746c320 100644 --- a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts @@ -218,12 +218,6 @@ export function mapDroidSdkMessageToChatEvents( case "mcp_status_changed": case "mcp_auth_required": case "mcp_auth_completed": - case "mission_state_changed": - case "mission_features_changed": - case "mission_progress_entry": - case "mission_heartbeat": - case "mission_worker_started": - case "mission_worker_completed": return []; case "error": return [{ diff --git a/apps/desktop/src/main/services/chat/permissionMapping.ts b/apps/desktop/src/main/services/chat/permissionMapping.ts new file mode 100644 index 000000000..785818cd6 --- /dev/null +++ b/apps/desktop/src/main/services/chat/permissionMapping.ts @@ -0,0 +1,27 @@ +import type { AgentChatPermissionMode } from "../../../shared/types/chat"; + +export function mapPermissionToClaude(mode: AgentChatPermissionMode | undefined): "default" | "plan" | "acceptEdits" | "bypassPermissions" { + if (mode === "full-auto") return "bypassPermissions"; + if (mode === "edit") return "acceptEdits"; + if (mode === "default") return "default"; + return "plan"; +} + +export function mapPermissionToCodex(mode: AgentChatPermissionMode | undefined): { + approvalPolicy: "untrusted" | "on-request" | "on-failure" | "never"; + sandbox: "read-only" | "workspace-write" | "danger-full-access"; +} | null { + if (mode === "full-auto") { + return { approvalPolicy: "never", sandbox: "danger-full-access" }; + } + if (mode === "edit") { + return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; + } + if (mode === "config-toml") { + return null; + } + if (mode === "default") { + return { approvalPolicy: "on-request", sandbox: "workspace-write" }; + } + return { approvalPolicy: "on-request", sandbox: "read-only" }; +} diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 84a27bf99..b26dde6c4 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { openKvDb, type AdeDb } from "../state/kvDb"; import { createComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService"; @@ -44,16 +44,12 @@ describe("computerUseArtifactBrokerService", () => { }); it("routes ingested artifacts to additional owners and persists review metadata", () => { - const missionService = { addArtifact: vi.fn() } as any; - const orchestratorService = { registerArtifact: vi.fn() } as any; const events: Array<{ type: string; artifactId: string }> = []; const broker = createComputerUseArtifactBrokerService({ db, projectId: "project-1", projectRoot, - missionService, - orchestratorService, logger: createLogger(), onEvent: (payload) => events.push({ type: payload.type, artifactId: payload.artifactId }), }); @@ -80,19 +76,12 @@ describe("computerUseArtifactBrokerService", () => { const routed = broker.routeArtifact({ artifactId, - owner: { kind: "mission", id: "mission-1" }, + owner: { kind: "linear_issue", id: "issue-1" }, }); expect(routed.links.map((link) => `${link.ownerKind}:${link.ownerId}`)).toEqual([ "lane:lane-1", - "mission:mission-1", + "linear_issue:issue-1", ]); - expect(missionService.addArtifact).toHaveBeenCalledWith(expect.objectContaining({ - missionId: "mission-1", - metadata: expect.objectContaining({ - brokerArtifactId: artifactId, - backendName: "agent-browser", - }), - })); const reviewed = broker.updateArtifactReview({ artifactId, @@ -118,14 +107,10 @@ describe("computerUseArtifactBrokerService", () => { }); it("reads image previews only from the project artifact directory", async () => { - const missionService = { addArtifact: vi.fn() } as any; - const orchestratorService = { registerArtifact: vi.fn() } as any; const broker = createComputerUseArtifactBrokerService({ db, projectId: "project-1", projectRoot, - missionService, - orchestratorService, logger: createLogger(), }); const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); @@ -144,14 +129,10 @@ describe("computerUseArtifactBrokerService", () => { }); it("rejects local file imports outside allowed artifact roots", () => { - const missionService = { addArtifact: vi.fn() } as any; - const orchestratorService = { registerArtifact: vi.fn() } as any; const broker = createComputerUseArtifactBrokerService({ db, projectId: "project-1", projectRoot, - missionService, - orchestratorService, logger: createLogger(), }); @@ -182,8 +163,6 @@ describe("computerUseArtifactBrokerService", () => { db, projectId: "project-1", projectRoot, - missionService: { addArtifact: vi.fn() } as any, - orchestratorService: { registerArtifact: vi.fn() } as any, logger: createLogger(), }); @@ -210,14 +189,10 @@ describe("computerUseArtifactBrokerService", () => { }); it("rejects symlinked artifact paths that escape the project artifact directory", () => { - const missionService = { addArtifact: vi.fn() } as any; - const orchestratorService = { registerArtifact: vi.fn() } as any; const broker = createComputerUseArtifactBrokerService({ db, projectId: "project-1", projectRoot, - missionService, - orchestratorService, logger: createLogger(), }); diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 4354a7e1a..3d6f19968 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -23,12 +23,7 @@ import type { ComputerUseEventPayload, } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { - normalizeComputerUseArtifactKind, - resolveReportArtifactKind, -} from "../../../shared/proofArtifacts"; -import type { createMissionService } from "../missions/missionService"; -import type { createOrchestratorService } from "../orchestrator/orchestratorService"; +import { normalizeComputerUseArtifactKind } from "../../../shared/proofArtifacts"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { SqlValue } from "../state/kvDb"; @@ -241,12 +236,10 @@ export function createComputerUseArtifactBrokerService(args: { db: AdeDb; projectId: string; projectRoot: string; - missionService: ReturnType; - orchestratorService: ReturnType; logger?: Logger | null; onEvent?: (payload: ComputerUseEventPayload) => void; }) { - const { db, projectId, projectRoot, missionService, orchestratorService, onEvent } = args; + const { db, projectId, projectRoot, onEvent } = args; const layout = resolveAdeLayout(projectRoot); const allowedImportRoots = Array.from(new Set([ layout.artifactsDir, @@ -457,62 +450,6 @@ export function createComputerUseArtifactBrokerService(args: { return toArtifactView(refreshed, readLinkRows([artifactId])); }; - const projectArtifact = (record: ComputerUseArtifactRecord, owners: ComputerUseArtifactOwner[]): void => { - const missionId = owners.find((owner) => owner.kind === "mission")?.id ?? null; - const runId = owners.find((owner) => owner.kind === "orchestrator_run")?.id ?? null; - const stepId = owners.find((owner) => owner.kind === "orchestrator_step")?.id ?? null; - const attemptId = owners.find((owner) => owner.kind === "orchestrator_attempt")?.id ?? null; - const laneId = owners.find((owner) => owner.kind === "lane")?.id ?? null; - - if (missionId) { - missionService.addArtifact({ - missionId, - artifactType: record.kind, - title: record.title, - description: record.description, - uri: record.uri, - laneId, - metadata: { - brokerArtifactId: record.id, - backendStyle: record.backendStyle, - backendName: record.backendName, - sourceToolName: record.sourceToolName, - originalType: record.originalType, - }, - createdBy: "system", - actor: "system", - }); - } - - if (missionId && runId && stepId && attemptId) { - orchestratorService.registerArtifact({ - missionId, - runId, - stepId, - attemptId, - artifactKey: record.kind, - kind: resolveReportArtifactKind({ - type: record.kind, - artifactKey: record.kind, - uri: record.uri, - metadata: record.metadata, - }), - value: record.uri, - metadata: { - brokerArtifactId: record.id, - title: record.title, - description: record.description, - backendStyle: record.backendStyle, - backendName: record.backendName, - sourceToolName: record.sourceToolName, - uri: record.uri, - ...record.metadata, - }, - declared: true, - }); - } - }; - const readArtifactRows = (query: string, params: SqlValue[]): ComputerUseArtifactRecord[] => db.all(query, params).map((row) => ({ id: row.id, @@ -641,7 +578,6 @@ export function createComputerUseArtifactBrokerService(args: { owner, }); } - projectArtifact(record, owners); emit({ type: "artifact-ingested", artifactId: record.id, @@ -723,7 +659,6 @@ export function createComputerUseArtifactBrokerService(args: { const existing = getLink(artifactId, owner); if (!existing) { insertLink(artifactId, owner); - projectArtifact(record, [owner]); emit({ type: "artifact-linked", artifactId, diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.ts b/apps/desktop/src/main/services/computerUse/controlPlane.ts index b8e6e70f5..8fd8e6bee 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.ts @@ -5,10 +5,16 @@ import type { ComputerUseArtifactView, ComputerUseBackendStatus, ComputerUseOwnerSnapshot, - PhaseCard, } from "../../../shared/types"; import type { ComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService"; +type ProofPhaseConfig = { + validationGate: { + required?: boolean; + evidenceRequirements?: string[]; + }; +}; + const COMPUTER_USE_KINDS: ComputerUseArtifactKind[] = [ "screenshot", "video_recording", @@ -22,7 +28,7 @@ export function getComputerUseArtifactKinds(): ComputerUseArtifactKind[] { } export function collectRequiredComputerUseKindsFromPhases( - phases: PhaseCard[], + phases: ProofPhaseConfig[], ): ComputerUseArtifactKind[] { const required = new Set(); const supported = new Set(COMPUTER_USE_KINDS); diff --git a/apps/desktop/src/main/services/computerUse/localComputerUse.ts b/apps/desktop/src/main/services/computerUse/localComputerUse.ts index 75b28f0c4..978e39892 100644 --- a/apps/desktop/src/main/services/computerUse/localComputerUse.ts +++ b/apps/desktop/src/main/services/computerUse/localComputerUse.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { MissionCloseoutRequirementKey, ValidationEvidenceRequirement } from "../../../shared/types"; +import type { ComputerUseArtifactKind } from "../../../shared/types"; import { commandExists } from "../ai/utils"; export type LocalComputerUseCapabilityState = "present" | "missing" | "blocked_by_capability"; @@ -114,7 +114,7 @@ export function getLocalComputerUseCapabilities(): LocalComputerUseCapabilities } export function getCapabilityForRequirement( - requirement: MissionCloseoutRequirementKey | ValidationEvidenceRequirement | string, + requirement: ComputerUseArtifactKind | string, ): LocalComputerUseCapability | null { const capabilities = getLocalComputerUseCapabilities(); switch (requirement) { diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index e0f3133e0..b02d48342 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -686,6 +686,10 @@ describe("projectConfigService - automation execution", () => { laneNameTemplate: "Auto {{trigger.issue.title}}", mission: { title: "Run mission" }, }, + modelConfig: { + orchestratorModel: { modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, + profileId: "legacy-profile", + }, }, { id: "preset-lane-rule", @@ -732,6 +736,21 @@ describe("projectConfigService - automation execution", () => { }, }, }, + { + id: "legacy-launch-mission-rule", + trigger: { type: "manual" }, + execution: { + kind: "built-in", + builtIn: { + actions: [ + { + type: "launch-mission", + sessionTitle: "Launch nightly", + }, + ], + }, + }, + }, ], }), "utf8", @@ -745,14 +764,26 @@ describe("projectConfigService - automation execution", () => { logger: makeLogger(), }); - const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule, builtInAgentRule] = service.get().effective.automations; + const [ + customRule, + presetRule, + requireTriggerLaneRule, + legacyPromptAtRunRule, + builtInAgentRule, + legacyLaunchMissionRule, + ] = service.get().effective.automations; expect(customRule.execution).toMatchObject({ - kind: "mission", + kind: "agent-session", laneMode: "create", laneNamePreset: "custom", laneNameTemplate: "Auto {{trigger.issue.title}}", - mission: { title: "Run mission" }, + session: { title: "Run mission" }, + }); + expect(customRule.prompt).toBe("Run mission"); + expect(customRule.modelConfig).toMatchObject({ + modelId: "anthropic/claude-sonnet-4-6", + thinkingLevel: "medium", }); expect(presetRule.execution).toMatchObject({ kind: "agent-session", @@ -782,6 +813,12 @@ describe("projectConfigService - automation execution", () => { ], }, }); + expect(legacyLaunchMissionRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "reuse", + session: { title: "Launch nightly" }, + }); + expect(legacyLaunchMissionRule.prompt).toBe("Launch nightly"); }); it("flags fixed target lanes on require-on-trigger automation execution", () => { diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index e36c5e4f3..3dd78c308 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -58,8 +58,6 @@ import type { LinearAutoDispatchAction, LinearSyncConfig, ModelConfig, - MissionModelConfig, - MissionPermissionConfig, NotificationsConfig, NotificationApnsConfig, ProjectIdentityConfig, @@ -84,12 +82,10 @@ const AUTOMATION_TOOL_FAMILIES: AutomationToolFamily[] = [ "github", "linear", "browser", - "mission", ]; const AUTOMATION_TRIGGER_TYPE_SET = new Set(AUTOMATION_TRIGGER_TYPES); const AUTOMATION_ACTION_TYPE_SET = new Set([ "agent-session", - "launch-mission", "predict-conflicts", "run-tests", "run-command", @@ -469,15 +465,43 @@ function coerceAutomationActiveHours(value: unknown): AutomationActiveHours | un function coerceModelConfig(value: unknown): ModelConfig | undefined { if (!isRecord(value)) return undefined; - const modelId = asString(value.modelId)?.trim(); + const legacyOrchestratorModel = isRecord(value.orchestratorModel) ? value.orchestratorModel : null; + const modelId = asString(value.modelId)?.trim() || asString(legacyOrchestratorModel?.modelId)?.trim(); if (!modelId) return undefined; return { ...(asString(value.provider)?.trim() ? { provider: asString(value.provider)!.trim() as ModelConfig["provider"] } : {}), modelId, - ...(asString(value.thinkingLevel)?.trim() ? { thinkingLevel: asString(value.thinkingLevel)!.trim() as ModelConfig["thinkingLevel"] } : {}), + ...(asString(value.thinkingLevel)?.trim() + ? { thinkingLevel: asString(value.thinkingLevel)!.trim() as ModelConfig["thinkingLevel"] } + : asString(legacyOrchestratorModel?.thinkingLevel)?.trim() + ? { thinkingLevel: asString(legacyOrchestratorModel?.thinkingLevel)!.trim() as ModelConfig["thinkingLevel"] } + : {}), }; } +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + const text = asString(value)?.trim(); + if (text) return text; + } + return undefined; +} + +function legacyMissionPrompt(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + const mission = isRecord(value.mission) ? value.mission : null; + return firstNonEmptyString(value.prompt, mission?.prompt, value.sessionTitle, value.title, mission?.title); +} + +function findLegacyLaunchMissionAction(values: unknown[]): Record | null { + for (const value of values) { + if (!Array.isArray(value)) continue; + const match = value.find((entry) => isRecord(entry) && asString(entry.type)?.trim() === "launch-mission"); + if (isRecord(match)) return match; + } + return null; +} + function coerceAutomationAction(value: unknown): AutomationAction | null { if (!isRecord(value)) return null; const typeRaw = asString(value.type)?.trim() ?? ""; @@ -500,7 +524,7 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { const sessionTitle = asString(value.sessionTitle); const modelConfig = coerceModelConfig(value.modelConfig); const codexFastMode = asBool(value.codexFastMode); - const permissionConfig = coerceMissionPermissionConfig(value.permissionConfig); + const permissionConfig = coerceAutomationPermissionConfig(value.permissionConfig); if (targetLaneId) out.targetLaneId = targetLaneId; if (suiteId != null) out.suiteId = suiteId; @@ -538,8 +562,10 @@ function coerceRunAdeActionConfig(value: unknown): AutomationAction["adeAction"] function coerceAutomationExecution(value: unknown): AutomationExecution | undefined { if (!isRecord(value)) return undefined; const kindRaw = asString(value.kind)?.trim() ?? ""; - const kind = kindRaw === "agent-session" || kindRaw === "mission" || kindRaw === "built-in" + const kind = kindRaw === "agent-session" || kindRaw === "built-in" ? kindRaw + : kindRaw === "mission" + ? "agent-session" : null; if (!kind) return undefined; @@ -567,15 +593,22 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi }; if (kind === "agent-session") { + const legacyTitle = kindRaw === "mission" ? legacyMissionPrompt(value) : undefined; const session = isRecord(value.session) ? { - ...(asString(value.session.title)?.trim() ? { title: asString(value.session.title)!.trim() } : {}), + ...(asString(value.session.title)?.trim() + ? { title: asString(value.session.title)!.trim() } + : legacyTitle + ? { title: legacyTitle } + : {}), ...(asString(value.session.reasoningEffort)?.trim() ? { reasoningEffort: asString(value.session.reasoningEffort)!.trim() } : {}), ...(asBool(value.session.codexFastMode) != null ? { codexFastMode: asBool(value.session.codexFastMode) } : {}), } - : undefined; + : legacyTitle + ? { title: legacyTitle } + : undefined; return { kind, ...sharedLaneFields, @@ -584,21 +617,21 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi }; } - if (kind === "mission") { - const mission = isRecord(value.mission) && asString(value.mission.title)?.trim() - ? { title: asString(value.mission.title)!.trim() } - : undefined; + const actions = isRecord(value.builtIn) && Array.isArray(value.builtIn.actions) + ? value.builtIn.actions.map(coerceAutomationAction).filter((x): x is AutomationAction => x != null) + : []; + const legacyLaunchMission = findLegacyLaunchMissionAction([ + isRecord(value.builtIn) ? value.builtIn.actions : undefined, + ]); + if (legacyLaunchMission && actions.length === 0) { + const title = legacyMissionPrompt(legacyLaunchMission); return { - kind, + kind: "agent-session", ...sharedLaneFields, ...(targetLaneId ? { targetLaneId } : {}), - ...(mission ? { mission } : {}), + ...(title ? { session: { title } } : {}), }; } - - const actions = isRecord(value.builtIn) && Array.isArray(value.builtIn.actions) - ? value.builtIn.actions.map(coerceAutomationAction).filter((x): x is AutomationAction => x != null) - : []; return { kind, ...sharedLaneFields, @@ -630,36 +663,17 @@ function coerceAutomationExecutor(value: unknown): AutomationExecutor | undefine }; } -function coerceMissionModelConfig(value: unknown): MissionModelConfig | undefined { - if (!isRecord(value) || !isRecord(value.orchestratorModel)) return undefined; - const modelId = asString(value.orchestratorModel.modelId)?.trim(); - if (!modelId) return undefined; - return { - ...(asString(value.profileId)?.trim() ? { profileId: asString(value.profileId)!.trim() } : {}), - orchestratorModel: { - ...(asString(value.orchestratorModel.provider)?.trim() - ? { provider: asString(value.orchestratorModel.provider)!.trim() } - : {}), - modelId, - ...(asString(value.orchestratorModel.thinkingLevel)?.trim() - ? { thinkingLevel: asString(value.orchestratorModel.thinkingLevel)!.trim() as MissionModelConfig["orchestratorModel"]["thinkingLevel"] } - : {}), - }, - ...(asNumber(value.decisionTimeoutCapHours) != null - ? { decisionTimeoutCapHours: asNumber(value.decisionTimeoutCapHours) as MissionModelConfig["decisionTimeoutCapHours"] } - : {}), - }; -} +type AutomationPermissionConfig = NonNullable; -function coerceMissionPermissionConfig(value: unknown): MissionPermissionConfig | undefined { +function coerceAutomationPermissionConfig(value: unknown): AutomationPermissionConfig | undefined { if (!isRecord(value)) return undefined; const cli = isRecord(value.cli) ? { - ...(asString(value.cli.mode)?.trim() ? { mode: asString(value.cli.mode)!.trim() as NonNullable["mode"] } : {}), + ...(asString(value.cli.mode)?.trim() ? { mode: asString(value.cli.mode)!.trim() as NonNullable["mode"] } : {}), ...(asString(value.cli.sandboxPermissions)?.trim() ? { sandboxPermissions: - asString(value.cli.sandboxPermissions)!.trim() as NonNullable["sandboxPermissions"] + asString(value.cli.sandboxPermissions)!.trim() as NonNullable["sandboxPermissions"] } : {}), ...(asStringArray(value.cli.writablePaths)?.length ? { writablePaths: asStringArray(value.cli.writablePaths) } : {}), @@ -667,23 +681,23 @@ function coerceMissionPermissionConfig(value: unknown): MissionPermissionConfig } : undefined; const inProcess = isRecord(value.inProcess) && asString(value.inProcess.mode)?.trim() - ? { mode: asString(value.inProcess.mode)!.trim() as NonNullable["mode"] } + ? { mode: asString(value.inProcess.mode)!.trim() as NonNullable["mode"] } : undefined; const providers = isRecord(value.providers) ? { ...(asString(value.providers.claude)?.trim() - ? { claude: asString(value.providers.claude)!.trim() as NonNullable["claude"] } + ? { claude: asString(value.providers.claude)!.trim() as NonNullable["claude"] } : {}), ...(asString(value.providers.codex)?.trim() - ? { codex: asString(value.providers.codex)!.trim() as NonNullable["codex"] } + ? { codex: asString(value.providers.codex)!.trim() as NonNullable["codex"] } : {}), ...(asString(value.providers.opencode)?.trim() - ? { opencode: asString(value.providers.opencode)!.trim() as NonNullable["opencode"] } + ? { opencode: asString(value.providers.opencode)!.trim() as NonNullable["opencode"] } : {}), ...(asString(value.providers.codexSandbox)?.trim() ? { codexSandbox: - asString(value.providers.codexSandbox)!.trim() as NonNullable["codexSandbox"] + asString(value.providers.codexSandbox)!.trim() as NonNullable["codexSandbox"] } : {}), ...(asStringArray(value.providers.writablePaths)?.length ? { writablePaths: asStringArray(value.providers.writablePaths) } : {}), @@ -796,10 +810,14 @@ function coerceAutomationRule(value: unknown): ConfigAutomationRule | null { : undefined; const execution = coerceAutomationExecution(value.execution); const executor = coerceAutomationExecutor(value.executor); - const modelConfig = coerceMissionModelConfig(value.modelConfig); - const permissionConfig = coerceMissionPermissionConfig(value.permissionConfig); + const modelConfig = coerceModelConfig(value.modelConfig); + const permissionConfig = coerceAutomationPermissionConfig(value.permissionConfig); const templateId = asString(value.templateId); - const prompt = asString(value.prompt); + const legacyLaunchMission = findLegacyLaunchMissionAction([ + value.actions, + isRecord(value.execution) && isRecord(value.execution.builtIn) ? value.execution.builtIn.actions : undefined, + ]); + const prompt = asString(value.prompt) ?? legacyMissionPrompt(value.execution) ?? legacyMissionPrompt(legacyLaunchMission); const reviewProfileRaw = asString(value.reviewProfile)?.trim(); const reviewProfile: AutomationReviewProfile | undefined = reviewProfileRaw === "quick" || @@ -1175,7 +1193,6 @@ const AI_TASK_KEYS: AiTaskRoutingKey[] = [ "handoff_summary", "continuity_summary", "context_compaction", - "mission_planning", "initial_context" ]; @@ -1185,7 +1202,6 @@ const AI_FEATURE_KEYS: AiFeatureKey[] = [ "commit_messages", "pr_descriptions", "terminal_summaries", - "mission_planning", "orchestrator", "initial_context" ]; @@ -1436,14 +1452,6 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const maxPerStepTokenBudget = asNumber(orchestratorRaw.maxPerStepTokenBudget); if (maxPerStepTokenBudget != null && maxPerStepTokenBudget > 0) orchestrator.maxPerStepTokenBudget = maxPerStepTokenBudget; - const defaultExecutionPolicy = isRecord(orchestratorRaw.defaultExecutionPolicy) - ? orchestratorRaw.defaultExecutionPolicy - : null; - if (defaultExecutionPolicy) { - orchestrator.defaultExecutionPolicy = - defaultExecutionPolicy as NonNullable["defaultExecutionPolicy"]>; - } - // Legacy defaultPlannerProvider is ignored -- use defaultOrchestratorModel instead. const autoResolveInterventions = asBool(orchestratorRaw.autoResolveInterventions); @@ -1800,10 +1808,6 @@ function mergeAiOrchestrator( const orchestrator = { ...(sharedOrchestrator ?? {}), ...(localOrchestrator ?? {}), - defaultExecutionPolicy: { - ...(sharedOrchestrator?.defaultExecutionPolicy ?? {}), - ...(localOrchestrator?.defaultExecutionPolicy ?? {}) - }, hooks: { ...(sharedOrchestrator?.hooks ?? {}), ...(localOrchestrator?.hooks ?? {}) @@ -2274,7 +2278,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF const legacyTrigger = coerceAutomationTrigger(entry.trigger); const baseExecution = entry.execution ?? ((entry.actions?.length ?? 0) > 0 ? { kind: "built-in" as const, builtIn: { actions: entry.actions ?? [] } } - : { kind: "mission" as const }); + : { kind: "agent-session" as const, session: {} }); // Lane-mode migration: legacy rules with a leading `create-lane` action collapse // into `execution.laneMode: "create"`, carrying the action's name template forward // as a "custom" preset. Rules without that action default to "reuse". @@ -2329,7 +2333,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(entry.templateId?.trim() ? { templateId: entry.templateId.trim() } : {}), ...(entry.prompt?.trim() ? { prompt: entry.prompt.trim() } : {}), reviewProfile: entry.reviewProfile ?? "quick", - toolPalette: entry.toolPalette?.length ? [...new Set(entry.toolPalette)] : ["repo", "mission"], + toolPalette: entry.toolPalette?.length ? [...new Set(entry.toolPalette)] : ["repo"], contextSources: entry.contextSources?.length ? entry.contextSources : [], guardrails: entry.guardrails ?? {}, outputs: entry.outputs ?? { disposition: "comment-only", createArtifact: true }, @@ -2907,7 +2911,6 @@ function validateEffectiveConfig( issues.push({ path: `${p}.execution`, message: "Automation execution kind is required." }); } else if ( rule.execution.kind !== "agent-session" - && rule.execution.kind !== "mission" && rule.execution.kind !== "built-in" ) { issues.push({ path: `${p}.execution.kind`, message: `Unknown execution kind '${String((rule.execution as { kind?: unknown }).kind)}'` }); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index 3b1f1a140..ccb45c072 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -647,16 +647,14 @@ describe("conflictService conflict context integrity", () => { model: "anthropic/claude-sonnet-4-6", reasoningEffort: "high", permissionMode: "full_edit", - originSurface: "mission", - originMissionId: "mission-1", + originSurface: "queue", originRunId: "run-1", - originLabel: "Mission finalization", + originLabel: "Queue finalization", }); expect(prepared.status).toBe("blocked"); const preparedSummary = service.listExternalResolverRuns({ laneId: "lane-1" }).find((entry) => entry.runId === prepared.runId); - expect(preparedSummary?.originSurface).toBe("mission"); - expect(preparedSummary?.originMissionId).toBe("mission-1"); + expect(preparedSummary?.originSurface).toBe("queue"); expect(preparedSummary?.originRunId).toBe("run-1"); expect(preparedSummary?.model).toBe("anthropic/claude-sonnet-4-6"); expect(preparedSummary?.reasoningEffort).toBe("high"); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index a32a4f3ae..bdd721b42 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -137,7 +137,6 @@ type ExternalResolverRunRecord = { reasoningEffort: string | null; permissionMode: PrAgentPermissionMode | null; originSurface: ConflictResolverOriginSurface; - originMissionId: string | null; originRunId: string | null; originLabel: string | null; resolverContextKey: string | null; @@ -1058,7 +1057,6 @@ export function createConflictService({ contextGaps: run.contextGaps, warnings: run.warnings, originSurface: run.originSurface, - originMissionId: run.originMissionId ?? null, originRunId: run.originRunId ?? null, originLabel: run.originLabel ?? null, resolverContextKey: run.resolverContextKey ?? null, @@ -3490,7 +3488,6 @@ export function createConflictService({ reasoningEffort: args.reasoningEffort ?? null, permissionMode: args.permissionMode ?? null, originSurface: args.originSurface ?? "manual", - originMissionId: args.originMissionId ?? null, originRunId: args.originRunId ?? null, originLabel: args.originLabel ?? null, }); @@ -4070,7 +4067,6 @@ export function createConflictService({ reasoningEffort: args.reasoningEffort ?? null, permissionMode: args.permissionMode ?? null, originSurface: args.originSurface ?? "manual", - originMissionId: args.originMissionId ?? null, originRunId: args.originRunId ?? null, originLabel: args.originLabel ?? null, resolverContextKey, diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index d7bed5e34..2a44d3620 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -426,7 +426,7 @@ describe("flowPolicyService", () => { enabled: true, priority: 100, triggers: { assignees: ["CTO"] }, - target: { type: "mission" }, + target: { type: "worker_run" }, steps: [{ id: "launch", type: "launch_target" }], }, { diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 81b643870..e90d816ea 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -84,7 +84,7 @@ function buildCtoModelSelectionKnowledge(): string[] { const IMMUTABLE_CTO_DOCTRINE = [ "You are the CTO for the current project inside ADE.", - "ADE (Autonomous Development Environment) is a local-first Electron desktop app that wraps your entire development workflow: git branching via lanes, AI chat sessions, terminal shells, PR management, mission orchestration, worker agents, conflict resolution, test execution, Linear integration, and more.", + "ADE (Autonomous Development Environment) is a local-first Electron desktop app that wraps your entire development workflow: git branching via lanes, AI chat sessions, terminal shells, PR management, worker agents, conflict resolution, test execution, Linear integration, and more.", "You are not a generic assistant. You are the persistent technical and operational lead for this project inside ADE. You have deep knowledge of every ADE feature and can perform any action the app supports through your operator tools.", "Answer identity questions as the project's CTO. Do not reframe yourself as Codex, Claude, or a detached chatbot.", "", @@ -140,12 +140,7 @@ function buildCtoEnvironmentKnowledge(): string { "", "PTY Terminal: A shell terminal session (runs any CLI command). Created with createTerminal({ laneId, title?, startupCommand? }). No ADE tool integration — use for raw shell commands only.", "", - "Mission: A structured, multi-step task unit with planning, execution runs, workers, and artifacts. Missions break down complex work into phases and steps.", - " - Lifecycle: draft → queued → planning → in_progress → completed/failed/cancelled.", - " - Missions can require intervention (human review at checkpoints).", - " - Tools: listMissions, startMission, getMissionStatus, launchMissionRun, steerMission, updateMission.", - "", - "Worker: A named agent instance (engineer, QA, researcher, etc.) that runs in a lane executing missions or tasks autonomously.", + "Worker: A named agent instance (engineer, QA, researcher, etc.) that runs in a lane executing tasks autonomously.", " - Workers have a budget, heartbeat, and can be woken with specific tasks.", " - Status: idle, active, running, paused.", " - Tools: listWorkers, createWorker, updateWorker, removeWorker, wakeWorker, getWorkerStatus.", @@ -166,7 +161,6 @@ function buildCtoEnvironmentKnowledge(): string { " /lanes — Lane browser showing all lanes, their status, git actions, diffs, stacks, and PR panels.", " /files — File explorer for browsing and editing project files.", " /prs — Pull request management: list, detail view, convergence, queue, GitHub integration.", - " /missions — Mission control center: create missions, monitor runs, view artifacts and logs.", " /cto — CTO settings page: your identity, team/workers, Linear integration.", " /graph — Workspace dependency graph visualization showing lane relationships.", " /history — Operation history timeline showing all past actions.", @@ -214,7 +208,7 @@ function buildCtoEnvironmentKnowledge(): string { " ADE integrates with Linear for issue tracking and workflow automation.", " - List and inspect Linear issues: listLinearIssues, getLinearIssue.", " - Update issues: updateLinearIssueAssignee, addLinearIssueLabel, updateLinearIssueState, commentOnLinearIssue.", - " - Route issues to work: routeLinearIssueToCto (handle yourself), routeLinearIssueToMission (auto-plan), routeLinearIssueToWorker (delegate).", + " - Route issues to work: routeLinearIssueToCto (handle yourself) or routeLinearIssueToWorker (delegate).", " - Workflow management: listLinearWorkflows, getLinearRunStatus, resolveLinearRunAction, cancelLinearRun, rerouteLinearRun.", "", "## Automation System", @@ -239,7 +233,7 @@ function buildCtoEnvironmentKnowledge(): string { " 'Start a chat' or 'launch an agent' → spawnChat({ modelId, initialPrompt, title }).", " 'Start a chat with opus/sonnet/gpt-5.5/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.", " 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.", - " 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.", + " 'Start work on [feature]' → create/find a lane, then spawnChat.", " 'Open a terminal' → createTerminal.", " 'Run the tests' → listTestSuites to find available suites, then runTests.", " 'Commit and push' → gitCommit then gitPush.", @@ -293,18 +287,6 @@ const CTO_CAPABILITY_MANIFEST = [ " listSubagents — List sub-agents spawned by a chat. Params: sessionId.", " approveToolUse — Approve or deny a pending tool use in a supervised chat. Params: sessionId, toolUseId, decision (accept/accept_for_session/decline/cancel).", "", - "## Missions (structured multi-step tasks)", - " listMissions — List all missions with status and summary.", - " startMission — Create and start a new mission. Params: title, description, laneId.", - " getMissionStatus — Get detailed mission status, progress, and outcomes. Params: missionId.", - " updateMission — Update mission title, description, or configuration. Params: missionId.", - " launchMissionRun — Launch or re-launch a mission execution run. Params: missionId.", - " resolveMissionIntervention — Resolve a human intervention checkpoint. Params: missionId, resolution.", - " getMissionRunView — Get the detailed run view with phase/step progress. Params: missionId.", - " getMissionLogs — Read mission execution logs. Params: missionId.", - " listMissionWorkerDigests — Get summaries of worker activity in a mission. Params: missionId.", - " steerMission — Inject a steering directive into a running mission. Params: missionId, instruction.", - "", "## Workers (autonomous agent instances)", " listWorkers — List all worker agents with status and budget info.", " createWorker — Create a new worker agent. Params: name, description, role, laneId.", @@ -394,7 +376,6 @@ const CTO_CAPABILITY_MANIFEST = [ " commentOnLinearIssue — Add a comment to a Linear issue. Params: issueId, body.", " updateLinearIssueState — Move a Linear issue to a new state. Params: issueId, stateId.", " routeLinearIssueToCto — Route a Linear issue to yourself (the CTO) for handling.", - " routeLinearIssueToMission — Auto-create a mission from a Linear issue. Params: issueId.", " routeLinearIssueToWorker — Delegate a Linear issue to a worker agent. Params: issueId, agentId.", " listLinearIssues — Search/list Linear issues. Params: projectSlug, query, limit.", " getLinearIssue — Get full detail of a Linear issue. Params: issueId.", @@ -407,8 +388,8 @@ const CTO_CAPABILITY_MANIFEST = [ " listAutomationRuns — List recent automation run history.", "", "## Events & Health", - " getRecentEvents — Unified feed of recent project events (sessions, worker activity, tests, PRs, missions, chats). Params: since, limit.", - " getProjectHealthSummary — Aggregate dashboard: mission counts, worker utilization, test pass rates, PR status, budget burn.", + " getRecentEvents — Unified feed of recent project events (sessions, worker activity, tests, PRs, chats). Params: since, limit.", + " getProjectHealthSummary — Aggregate dashboard: worker utilization, test pass rates, PR status, budget burn.", "", "## Computer Use", " listComputerUseArtifacts — List browser screenshots and artifacts from computer use sessions.", diff --git a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts index 19c97e075..c031afa35 100644 --- a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts +++ b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts @@ -1678,13 +1678,11 @@ describe("workerTaskSessionService (file group)", () => { const keyA = fixture.service.deriveTaskKey({ agentId: "a1", laneId: "lane-x", - missionId: "mission-1", summary: "fix checkout bug", }); const keyB = fixture.service.deriveTaskKey({ agentId: "a1", laneId: "lane-x", - missionId: "mission-1", summary: "fix checkout bug", }); expect(keyA).toBe(keyB); diff --git a/apps/desktop/src/main/services/cto/linearCloseoutService.ts b/apps/desktop/src/main/services/cto/linearCloseoutService.ts index 4ae364198..7faee0668 100644 --- a/apps/desktop/src/main/services/cto/linearCloseoutService.ts +++ b/apps/desktop/src/main/services/cto/linearCloseoutService.ts @@ -4,12 +4,9 @@ import type { LinearWorkflowRun, NormalizedLinearIssue, } from "../../../shared/types"; -import { resolveOrchestratorArtifactUri } from "../../../shared/proofArtifacts"; import type { Logger } from "../logging/logger"; import type { createLinearOutboundService } from "./linearOutboundService"; import type { IssueTracker } from "./issueTracker"; -import type { createMissionService } from "../missions/missionService"; -import type { createOrchestratorService } from "../orchestrator/orchestratorService"; import type { createPrService } from "../prs/prService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { renderTemplateString } from "../shared/utils"; @@ -41,8 +38,6 @@ function resolveStateId(states: Array<{ id: string; name: string; type: string } export function createLinearCloseoutService(args: { issueTracker: IssueTracker; outboundService: ReturnType; - missionService: ReturnType; - orchestratorService: ReturnType; prService: ReturnType; computerUseArtifactBrokerService: ReturnType; logger?: Logger | null; @@ -103,40 +98,8 @@ export function createLinearCloseoutService(args: { input.run.linkedPrId ? `Linked PR record: ${input.run.linkedPrId}` : null, ]); - if (input.workflow.target.type === "mission" && input.run.linkedMissionId) { - const mission = args.missionService.get(input.run.linkedMissionId); - const missionArtifactUris = mission?.artifacts - .map((artifact) => artifact.uri) - .filter(isNonEmptyString) ?? []; - const missionPrLinks = mission?.artifacts - .filter((artifact) => artifact.artifactType === "pr") - .map((artifact) => artifact.uri) - .filter(isNonEmptyString) ?? []; - const orchestratorArtifacts = args.orchestratorService.getArtifactsForMission(input.run.linkedMissionId); - const orchestratorUris = orchestratorArtifacts - .map((artifact) => resolveOrchestratorArtifactUri({ - kind: artifact.kind, - value: artifact.value, - metadata: artifact.metadata, - })) - .filter(isNonEmptyString); - const orchestratorPrLinks = orchestratorArtifacts - .filter((artifact) => artifact.kind === "pr") - .map((artifact) => resolveOrchestratorArtifactUri({ - kind: artifact.kind, - value: artifact.value, - metadata: artifact.metadata, - })) - .filter(isNonEmptyString); - return { - prLinks: uniqueStrings([...prLinks, ...missionPrLinks, ...orchestratorPrLinks]), - artifactPaths: uniqueStrings([...missionArtifactUris, ...orchestratorUris]), - contextLines, - }; - } - const owners: ComputerUseArtifactOwner[] = []; - owners.push({ kind: "orchestrator_run", id: input.run.id }); + owners.push({ kind: "automation_run", id: input.run.id }); if (input.run.linkedSessionId) { owners.push({ kind: "chat_session", id: input.run.linkedSessionId }); } @@ -182,7 +145,6 @@ export function createLinearCloseoutService(args: { id: input.run.linkedSessionId ?? input.run.linkedWorkerRunId - ?? input.run.linkedMissionId ?? input.run.linkedPrId ?? input.run.executionLaneId ?? null, @@ -232,21 +194,6 @@ export function createLinearCloseoutService(args: { }, }; - if (input.workflow.target.type === "mission" && input.run.linkedMissionId) { - await args.outboundService.publishMissionCloseout({ - issue: input.issue, - missionId: input.run.linkedMissionId, - status: outboundStatus, - summary: input.summary, - prLinks: closeoutArtifacts.prLinks, - artifactPaths: closeoutArtifacts.artifactPaths, - artifactMode: closeout?.artifactMode ?? "links", - commentTemplate: closeout?.commentTemplate ?? null, - templateValues: outboundTemplateValues, - }); - return; - } - await args.outboundService.publishWorkflowCloseout({ issue: input.issue, status: outboundStatus, diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.ts index 9748f8b68..1a6433a97 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.ts @@ -24,8 +24,6 @@ import type { import type { AdeDb } from "../state/kvDb"; import { nowIso, safeJsonParse } from "../shared/utils"; import type { createAgentChatService } from "../chat/agentChatService"; -import type { createMissionService } from "../missions/missionService"; -import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; import type { createLaneService } from "../lanes/laneService"; import type { createPrService } from "../prs/prService"; import type { createWorkerHeartbeatService } from "./workerHeartbeatService"; @@ -50,7 +48,6 @@ type RunRow = { current_step_index: number; current_step_id: string | null; execution_lane_id: string | null; - linked_mission_id: string | null; linked_session_id: string | null; linked_worker_run_id: string | null; linked_pr_id: string | null; @@ -139,7 +136,6 @@ function toRun(row: RunRow): LinearWorkflowRun { currentStepIndex: row.current_step_index, currentStepId: row.current_step_id, executionLaneId: row.execution_lane_id, - linkedMissionId: row.linked_mission_id, linkedSessionId: row.linked_session_id, linkedWorkerRunId: row.linked_worker_run_id, linkedPrId: row.linked_pr_id, @@ -207,9 +203,6 @@ function resolveWorkflowTargetStatus( targetType: LinearWorkflowDefinition["target"]["type"], targetStatus?: LinearWorkflowTargetStatus | null, ): LinearWorkflowTargetStatus { - if (targetType === "mission") { - return targetStatus ?? "completed"; - } if (!targetStatus || targetStatus === "completed") { return "explicit_completion"; } @@ -234,8 +227,6 @@ export function createLinearDispatcherService(args: { issueTracker: IssueTracker; workerAgentService: WorkerAgentService; workerHeartbeatService: ReturnType; - missionService: ReturnType; - aiOrchestratorService: ReturnType; agentChatService: ReturnType; laneService: ReturnType; templateService: LinearTemplateService; @@ -357,7 +348,6 @@ export function createLinearDispatcherService(args: { currentStepIndex: number; currentStepId: string | null; executionLaneId: string | null; - linkedMissionId: string | null; linkedSessionId: string | null; linkedWorkerRunId: string | null; linkedPrId: string | null; @@ -385,7 +375,6 @@ export function createLinearDispatcherService(args: { current_step_index = ?, current_step_id = ?, execution_lane_id = ?, - linked_mission_id = ?, linked_session_id = ?, linked_worker_run_id = ?, linked_pr_id = ?, @@ -412,7 +401,6 @@ export function createLinearDispatcherService(args: { patch.currentStepIndex ?? existing.current_step_index, patch.currentStepId === undefined ? existing.current_step_id : patch.currentStepId, patch.executionLaneId === undefined ? existing.execution_lane_id : patch.executionLaneId, - patch.linkedMissionId === undefined ? existing.linked_mission_id : patch.linkedMissionId, patch.linkedSessionId === undefined ? existing.linked_session_id : patch.linkedSessionId, patch.linkedWorkerRunId === undefined ? existing.linked_worker_run_id : patch.linkedWorkerRunId, patch.linkedPrId === undefined ? existing.linked_pr_id : patch.linkedPrId, @@ -487,7 +475,7 @@ export function createLinearDispatcherService(args: { }; const hasDurableLaunchMarker = (run: LinearWorkflowRun): boolean => - Boolean(run.linkedMissionId || run.linkedSessionId || run.linkedWorkerRunId || run.linkedPrId); + Boolean(run.linkedSessionId || run.linkedWorkerRunId || run.linkedPrId); const updateStep = (stepId: string, patch: Partial<{ status: StepRow["status"]; @@ -753,7 +741,6 @@ export function createLinearDispatcherService(args: { const payload: Record = { activeTargetType: run.executionContext?.activeTargetType ?? activeTarget.type, laneId: run.executionLaneId, - missionId: run.linkedMissionId, sessionId: run.linkedSessionId, workerRunId: run.linkedWorkerRunId, prId: run.linkedPrId, @@ -795,7 +782,7 @@ export function createLinearDispatcherService(args: { issue: NormalizedLinearIssue, ): string => { const rendered = args.templateService.renderTemplate({ - templateId: target.sessionTemplate ?? target.missionTemplate ?? "default", + templateId: target.sessionTemplate ?? "default", issue, route: { workflowId: workflow.id, @@ -882,7 +869,6 @@ export function createLinearDispatcherService(args: { currentStep: currentStep?.name ?? currentStep?.id ?? null, delegatedOwner, laneId: run.executionLaneId, - missionId: run.linkedMissionId, sessionId: run.linkedSessionId, workerRunId: run.linkedWorkerRunId, prId: run.linkedPrId, @@ -899,7 +885,7 @@ export function createLinearDispatcherService(args: { run, target: { type: run.executionContext?.activeTargetType ?? run.targetType, - id: run.linkedSessionId ?? run.linkedWorkerRunId ?? run.linkedMissionId ?? run.linkedPrId ?? run.executionLaneId ?? null, + id: run.linkedSessionId ?? run.linkedWorkerRunId ?? run.linkedPrId ?? run.executionLaneId ?? null, owner: delegatedOwner, }, pr: { @@ -997,7 +983,7 @@ export function createLinearDispatcherService(args: { target: LinearWorkflowDefinition["target"], issue: NormalizedLinearIssue ): Promise => { - if (target.type === "mission" || target.type === "review_gate") { + if (target.type === "review_gate") { return null; } if (run.executionLaneId) return run.executionLaneId; @@ -1140,38 +1126,6 @@ export function createLinearDispatcherService(args: { } } - if (target.type === "mission") { - if (!run.linkedMissionId) { - return { state: "failed", payload: { targetStatus, reason: "missing_mission_link" } }; - } - const mission = args.missionService.get(run.linkedMissionId); - if (!mission) { - return { state: "failed", payload: { targetStatus, missionId: run.linkedMissionId, reason: "mission_not_found" } }; - } - if (mission.status === "completed") { - return { - state: targetStatusAllowsTerminalSuccess(targetStatus) ? "completed" : "waiting", - payload: { targetStatus, missionId: run.linkedMissionId, missionStatus: mission.status }, - }; - } - if (mission.status === "failed") { - return { - state: "failed", - payload: { targetStatus, missionId: run.linkedMissionId, missionStatus: mission.status }, - }; - } - if (mission.status === "canceled") { - return { - state: "cancelled", - payload: { targetStatus, missionId: run.linkedMissionId, missionStatus: mission.status }, - }; - } - return { - state: "waiting", - payload: { targetStatus, missionId: run.linkedMissionId, missionStatus: mission.status }, - }; - } - if (target.type === "employee_session") { const launchContext = getLaunchContext(run.id); const identityKey = typeof launchContext.sessionIdentityKey === "string" ? launchContext.sessionIdentityKey.trim() : ""; @@ -1311,57 +1265,6 @@ export function createLinearDispatcherService(args: { overrideSource, }); - if (target.type === "mission") { - const rendered = args.templateService.renderTemplate({ - templateId: target.missionTemplate ?? "default", - issue, - route: { - workflowId: workflow.id, - workflowName: workflow.name, - }, - worker: worker ? { id: worker.id, slug: worker.slug } : {}, - }); - const mission = args.missionService.create({ - title: `${issue.identifier}: ${issue.title}`, - prompt: rendered.prompt, - priority: issue.priorityLabel === "urgent" ? "urgent" : issue.priorityLabel === "high" ? "high" : issue.priorityLabel === "low" ? "low" : "normal", - autostart: false, - launchMode: target.runMode === "manual" ? "manual" : "autopilot", - employeeAgentId: worker?.id, - ...(target.prStrategy ? { executionPolicy: { prStrategy: target.prStrategy } } : {}), - ...(target.phaseProfile ? { phaseProfileId: target.phaseProfile } : {}), - }); - await args.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: target.runMode === "manual" ? "manual" : "autopilot", - metadata: { - source: "linear_workflow", - linearIssueId: issue.id, - workflowId: workflow.id, - }, - }); - updateRun(run.id, { - linkedMissionId: mission.id, - status: "waiting_for_target", - }); - updateExecutionState(run.id, { - waitingFor: "delegated work", - stalledReason: null, - workerId: worker?.id ?? null, - workerSlug: worker?.slug ?? null, - sessionLabel: null, - }); - const missionPatch: Partial & Record = { - linkedMissionId: mission.id, - status: "waiting_for_target", - workerId: worker?.id ?? null, - workerSlug: worker?.slug ?? null, - sessionLabel: null, - activeTargetType: target.type, - }; - return missionPatch; - } - if (target.type === "employee_session") { if (!employeeTarget || !employeeTarget.identityKey) { appendEvent(run.id, "run.awaiting_delegation", "waiting", `No employee could be resolved for workflow '${workflow.name}'. Queued for manual delegation.`); @@ -1862,7 +1765,6 @@ export function createLinearDispatcherService(args: { || !isEmployeeOverrideCompatibleWithTarget(policy, nextTarget.type, currentOverride) ); updateRun(run.id, { - linkedMissionId: null, linkedSessionId: null, linkedWorkerRunId: null, }); @@ -2080,7 +1982,6 @@ export function createLinearDispatcherService(args: { currentStepId: workflow.steps[loopIndex]?.id ?? null, reviewState: "changes_requested", reviewReadyReason: null, - linkedMissionId: null, linkedSessionId: null, linkedWorkerRunId: null, }); @@ -2295,11 +2196,11 @@ export function createLinearDispatcherService(args: { ` insert into linear_workflow_runs( id, project_id, issue_id, identifier, title, workflow_id, workflow_name, workflow_version, source, target_type, - status, current_step_index, current_step_id, execution_lane_id, linked_mission_id, linked_session_id, linked_worker_run_id, linked_pr_id, + status, current_step_index, current_step_id, execution_lane_id, linked_session_id, linked_worker_run_id, linked_pr_id, review_state, supervisor_identity_key, review_ready_reason, pr_state, pr_checks_status, pr_review_status, latest_review_note, retry_count, retry_after, closeout_state, terminal_outcome, route_context_json, execution_context_json, source_issue_snapshot_json, last_error, created_at, updated_at ) - values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, null, null, null, null, null, null, null, null, null, null, null, null, 0, null, 'pending', null, ?, ?, ?, null, ?, ?) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, null, null, null, null, null, null, null, null, null, null, null, 0, null, 'pending', null, ?, ?, ?, null, ?, ?) `, [ id, @@ -2418,7 +2319,7 @@ export function createLinearDispatcherService(args: { }); } if (trimmedLaneId !== undefined) { - if (!activeTarget || activeTarget.type === "mission" || activeTarget.type === "review_gate") { + if (!activeTarget || activeTarget.type === "review_gate") { throw new Error("This workflow target does not use an execution lane."); } const resolvedLaneId = await resolveOperatorLane(trimmedLaneId); @@ -2460,7 +2361,6 @@ export function createLinearDispatcherService(args: { currentStepIndex: 0, currentStepId: null, latestReviewNote: note ?? run.latestReviewNote, - linkedMissionId: null, linkedSessionId: null, linkedWorkerRunId: null, }); @@ -2535,7 +2435,6 @@ export function createLinearDispatcherService(args: { status: reviewContext?.rejectAction === "loop_back" ? "queued" : "in_progress", lastError: note ?? "Rejected by reviewer.", ...(reviewContext?.rejectAction === "loop_back" ? { - linkedMissionId: null, linkedSessionId: null, linkedWorkerRunId: null, } : {}), @@ -2598,7 +2497,6 @@ export function createLinearDispatcherService(args: { currentStepIndex: 0, currentStepId: null, latestReviewNote: note ?? run.latestReviewNote, - linkedMissionId: null, linkedSessionId: null, linkedWorkerRunId: null, }); @@ -2662,7 +2560,6 @@ export function createLinearDispatcherService(args: { workerId: delegatedContext.workerId, workerSlug: delegatedContext.workerSlug, sessionLabel: delegatedContext.sessionLabel, - missionId: row.linked_mission_id, sessionId: row.linked_session_id, workerRunId: row.linked_worker_run_id, prId: row.linked_pr_id, diff --git a/apps/desktop/src/main/services/cto/linearIntake.test.ts b/apps/desktop/src/main/services/cto/linearIntake.test.ts index 9749a006a..34b60723d 100644 --- a/apps/desktop/src/main/services/cto/linearIntake.test.ts +++ b/apps/desktop/src/main/services/cto/linearIntake.test.ts @@ -77,7 +77,7 @@ describe("linearIntakeService (file group)", () => { enabled: true, priority: 100, triggers: { assignees: ["CTO"], projectSlugs: ["acme-platform"] }, - target: { type: "mission" }, + target: { type: "worker_run" }, steps: [{ id: "launch", type: "launch_target" }], }, ], @@ -154,7 +154,7 @@ describe("linearIntakeService (file group)", () => { enabled: true, priority: 100, triggers: { projectSlugs: ["extra-project"] }, - target: { type: "mission" }, + target: { type: "worker_run" }, steps: [{ id: "launch", type: "launch_target" }], }, ], @@ -573,7 +573,7 @@ describe("linearCloseoutService (file group)", () => { enabled: true, priority: 100, triggers: { projectSlugs: ["acme-platform"] }, - target: { type: "mission" }, + target: { type: "worker_run" }, steps: [], closeout: { successState: "done", @@ -600,14 +600,13 @@ describe("linearCloseoutService (file group)", () => { workflowName: workflowFixture.name, workflowVersion: "2026-03-12T00:00:00.000Z", source: "repo", - targetType: "mission", + targetType: "worker_run", status: "in_progress", currentStepIndex: 0, currentStepId: null, executionLaneId: null, - linkedMissionId: "mission-1", linkedSessionId: null, - linkedWorkerRunId: null, + linkedWorkerRunId: "worker-run-1", linkedPrId: null, reviewState: null, supervisorIdentityKey: null, @@ -627,96 +626,7 @@ describe("linearCloseoutService (file group)", () => { }; describe("linearCloseoutService", () => { - it("merges mission and orchestrator proof artifacts into Linear closeout payload", async () => { - const publishMissionCloseout = vi.fn(async () => {}); - const issueTracker = { - fetchWorkflowStates: vi.fn(async () => [ - { id: "state-done", name: "Done", type: "completed" }, - { id: "state-blocked", name: "Blocked", type: "started" }, - ]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - }; - - const service = createLinearCloseoutService({ - issueTracker: issueTracker as any, - outboundService: { - publishMissionCloseout, - } as any, - missionService: { - get: vi.fn(() => ({ - id: "mission-1", - artifacts: [ - { id: "art-1", artifactType: "pr", uri: "https://github.com/acme/repo/pull/42" }, - { id: "art-2", artifactType: "note", uri: "https://example.com/mission-note" }, - ], - })), - } as any, - orchestratorService: { - getArtifactsForMission: vi.fn(() => [ - { - id: "orch-1", - kind: "screenshot", - value: ".ade/artifacts/computer-use/shot.png", - metadata: {}, - }, - { - id: "orch-2", - kind: "pr", - value: "https://github.com/acme/repo/pull/43", - metadata: {}, - }, - { - id: "orch-3", - kind: "file", - value: "", - metadata: { uri: "https://example.com/browser-trace.zip" }, - }, - ]), - } as any, - prService: { - listAll: vi.fn(() => []), - getForLane: vi.fn(() => null), - } as any, - computerUseArtifactBrokerService: { - listArtifacts: vi.fn(() => []), - } as any, - }); - - await service.applyOutcome({ - run: runFixture, - workflow: workflowFixture, - issue: issueFixture, - outcome: "completed", - summary: "Validation evidence captured and closeout completed.", - }); - - expect(issueTracker.updateIssueState).toHaveBeenCalledWith(issueFixture.id, "state-done"); - expect(issueTracker.addLabel).toHaveBeenCalledWith(issueFixture.id, "ade"); - expect(issueTracker.createComment).toHaveBeenCalledWith(issueFixture.id, "Closeout applied."); - expect(publishMissionCloseout).toHaveBeenCalledWith(expect.objectContaining({ - issue: issueFixture, - missionId: "mission-1", - status: "completed", - summary: "Validation evidence captured and closeout completed.", - prLinks: [ - "https://github.com/acme/repo/pull/42", - "https://github.com/acme/repo/pull/43", - ], - artifactPaths: [ - "https://github.com/acme/repo/pull/42", - "https://example.com/mission-note", - ".ade/artifacts/computer-use/shot.png", - "https://github.com/acme/repo/pull/43", - "https://example.com/browser-trace.zip", - ], - artifactMode: "links", - commentTemplate: null, - })); - }); - - it("publishes non-mission PR links and broker artifacts to the generic Linear closeout", async () => { + it("publishes PR links and broker artifacts to the generic Linear closeout", async () => { const publishWorkflowCloseout = vi.fn(async () => {}); const service = createLinearCloseoutService({ issueTracker: { @@ -726,15 +636,8 @@ describe("linearCloseoutService (file group)", () => { createComment: vi.fn(async () => ({ commentId: "comment-1" })), } as any, outboundService: { - publishMissionCloseout: vi.fn(async () => {}), publishWorkflowCloseout, } as any, - missionService: { - get: vi.fn(() => null), - } as any, - orchestratorService: { - getArtifactsForMission: vi.fn(() => []), - } as any, prService: { listAll: vi.fn(() => [{ id: "pr-99", githubUrl: "https://github.com/acme/repo/pull/99" }]), getForLane: vi.fn(() => null), @@ -759,8 +662,8 @@ describe("linearCloseoutService (file group)", () => { run: { ...runFixture, targetType: "employee_session", - linkedMissionId: null, linkedSessionId: "session-1", + linkedWorkerRunId: null, linkedPrId: "pr-99", executionLaneId: "lane-1", }, diff --git a/apps/desktop/src/main/services/cto/linearOutboundService.ts b/apps/desktop/src/main/services/cto/linearOutboundService.ts index 23cdda987..83a34383c 100644 --- a/apps/desktop/src/main/services/cto/linearOutboundService.ts +++ b/apps/desktop/src/main/services/cto/linearOutboundService.ts @@ -145,50 +145,6 @@ export function createLinearOutboundService(args: { ].join("\n"); }; - const publishMissionStart = async (params: { - issue: NormalizedLinearIssue; - missionId: string; - missionTitle: string; - templateId: string; - routeReason: string; - workerName?: string | null; - }): Promise => { - const body = [ - buildHeader(params.issue), - "### Status", - "- State: In Progress", - `- Mission: ${params.missionId}`, - `- Mission title: ${params.missionTitle}`, - `- Template: ${params.templateId}`, - `- Worker: ${params.workerName ?? "auto"}`, - "", - "### Plan", - `- ${params.routeReason}`, - "- Execute implementation and tests before closeout.", - ].join("\n"); - - await ensureWorkpad({ issueId: params.issue.id, initialBody: body }); - }; - - const publishMissionProgress = async (params: { - issue: NormalizedLinearIssue; - missionId: string; - status: string; - stepSummary?: string; - lastError?: string | null; - }): Promise => { - const body = [ - buildHeader(params.issue), - "### Status", - `- State: ${params.status}`, - `- Mission: ${params.missionId}`, - ...(params.stepSummary ? ["", "### Progress", `- ${params.stepSummary}`] : []), - ...(params.lastError ? ["", "### Latest Error", `- ${params.lastError}`] : []), - ].join("\n"); - - await updateWorkpad({ issueId: params.issue.id, body }); - }; - const publishWorkflowStatus = async (params: { issue: NormalizedLinearIssue; workflowName: string; @@ -198,7 +154,6 @@ export function createLinearOutboundService(args: { currentStep?: string | null; delegatedOwner?: string | null; laneId?: string | null; - missionId?: string | null; sessionId?: string | null; workerRunId?: string | null; prId?: string | null; @@ -219,7 +174,6 @@ export function createLinearOutboundService(args: { ...(params.currentStep ? [`- Current step: ${params.currentStep}`] : []), ...(params.delegatedOwner ? [`- Delegated owner: ${params.delegatedOwner}`] : []), ...(params.laneId ? [`- Lane: ${params.laneId}`] : []), - ...(params.missionId ? [`- Mission: ${params.missionId}`] : []), ...(params.sessionId ? [`- Session: ${params.sessionId}`] : []), ...(params.workerRunId ? [`- Worker run: ${params.workerRunId}`] : []), ...(params.prId ? [`- PR: ${params.prId}`] : []), @@ -242,7 +196,6 @@ export function createLinearOutboundService(args: { status: params.state, currentStep: params.currentStep ?? null, laneId: params.laneId ?? null, - missionId: params.missionId ?? null, sessionId: params.sessionId ?? null, workerRunId: params.workerRunId ?? null, prId: params.prId ?? null, @@ -250,7 +203,7 @@ export function createLinearOutboundService(args: { target: { type: params.targetType, owner: params.delegatedOwner ?? null, - id: params.sessionId ?? params.workerRunId ?? params.missionId ?? params.prId ?? params.laneId ?? null, + id: params.sessionId ?? params.workerRunId ?? params.prId ?? params.laneId ?? null, }, pr: { id: params.prId ?? null, @@ -355,31 +308,6 @@ export function createLinearOutboundService(args: { return uploaded; }; - const publishMissionCloseout = async (params: { - issue: NormalizedLinearIssue; - missionId: string; - status: "completed" | "failed" | "canceled"; - summary: string; - prLinks?: string[]; - artifactPaths?: string[]; - artifactMode: LinearArtifactMode; - commentTemplate?: string | null; - templateValues?: Record; - }): Promise => { - await publishWorkflowCloseout({ - issue: params.issue, - status: params.status, - summary: params.summary, - targetLabel: "Mission", - targetId: params.missionId, - prLinks: params.prLinks, - artifactPaths: params.artifactPaths, - artifactMode: params.artifactMode, - commentTemplate: params.commentTemplate, - templateValues: params.templateValues, - }); - }; - const publishWorkflowCloseout = async (params: { issue: NormalizedLinearIssue; status: "completed" | "failed" | "canceled"; @@ -449,11 +377,8 @@ export function createLinearOutboundService(args: { return { ensureWorkpad, updateWorkpad, - publishMissionStart, - publishMissionProgress, publishWorkflowStatus, publishWorkflowCloseout, - publishMissionCloseout, }; } diff --git a/apps/desktop/src/main/services/cto/linearSync.test.ts b/apps/desktop/src/main/services/cto/linearSync.test.ts index dfcd7bd00..449a4b5df 100644 --- a/apps/desktop/src/main/services/cto/linearSync.test.ts +++ b/apps/desktop/src/main/services/cto/linearSync.test.ts @@ -855,7 +855,7 @@ describe("linearDispatcherService (file group)", () => { terminalStateTypes: ["completed", "canceled"], }; - function buildPolicy(targetType: "mission" | "review_gate" | "worker_run"): LinearWorkflowConfig { + function buildPolicy(targetType: "review_gate" | "worker_run"): LinearWorkflowConfig { return { version: 1, source: "repo", @@ -873,7 +873,7 @@ describe("linearDispatcherService (file group)", () => { { id: "launch", type: "launch_target", name: "Launch target" }, ...(targetType === "review_gate" ? [{ id: "review", type: "request_human_review", name: "Review gate" } as const] - : [{ id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: targetType === "mission" ? "completed" : "runtime_completed" } as const]), + : [{ id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" } as const]), { id: "complete", type: "complete_issue", name: "Complete issue" }, ], closeout: { successState: "done", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, @@ -1263,61 +1263,12 @@ describe("linearDispatcherService (file group)", () => { return { ensureWorkpad: vi.fn(async () => ({ commentId: "comment-1" })), updateWorkpad: vi.fn(async () => ({ commentId: "comment-1" })), - publishMissionStart: vi.fn(async () => {}), - publishMissionProgress: vi.fn(async () => {}), publishWorkflowStatus: vi.fn(async () => {}), publishWorkflowCloseout: vi.fn(async () => {}), - publishMissionCloseout: vi.fn(async () => {}), } as any; } describe("linearDispatcherService", () => { - it("launches a mission target and records the mission id", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildPolicy("mission"); - const missionCreate = vi.fn(() => ({ id: "mission-1", title: "Mission" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => [{ id: "done", name: "Done", type: "completed", teamId: "team-1", teamKey: "ACME" }]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", capabilities: [] }]) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: missionCreate, get: vi.fn(() => ({ id: "mission-1", status: "completed", artifacts: [] })) } as any, - aiOrchestratorService: { startMissionRun: vi.fn(async () => ({ runId: "run-1" })) } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), - sendMessage: vi.fn(async () => {}), - listSessions: vi.fn(async () => []), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun(issueFixture, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - expect(missionCreate).toHaveBeenCalledTimes(1); - expect(dispatcher.listQueue()[0]?.missionId).toBe("mission-1"); - db.close(); - }); - it("holds review_gate targets in escalated status", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-review-")); const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); @@ -1335,8 +1286,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), sendMessage: vi.fn(async () => {}), @@ -1385,8 +1334,6 @@ describe("linearDispatcherService (file group)", () => { getAgent: vi.fn(() => null), } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), sendMessage, @@ -1436,8 +1383,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), sendMessage: vi.fn(async () => {}), @@ -1489,8 +1434,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, @@ -1543,8 +1486,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession, sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -1598,8 +1539,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession, sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -1651,8 +1590,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession, sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -1725,8 +1662,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-1" })), sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -1801,8 +1736,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession, sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -1870,8 +1803,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, @@ -1923,8 +1854,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession, sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -1985,8 +1914,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup, listRuns: vi.fn(() => []), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -2036,8 +1963,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession, sendMessage: vi.fn(async () => { @@ -2097,8 +2022,6 @@ describe("linearDispatcherService (file group)", () => { } as any, workerAgentService: { listAgents: vi.fn(() => []) } as any, workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-1" })), sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -2176,8 +2099,6 @@ describe("linearDispatcherService (file group)", () => { const closeoutService = createLinearCloseoutService({ issueTracker, outboundService, - missionService: { get: vi.fn(() => null) } as any, - orchestratorService: { getArtifactsForMission: vi.fn(() => []) } as any, prService, computerUseArtifactBrokerService: { listArtifacts: vi.fn(() => [{ uri: artifactPath }]), @@ -2195,8 +2116,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), @@ -2253,8 +2172,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-2" })), sendMessage: vi.fn(async () => ({ id: "message-1" })), @@ -2314,8 +2231,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns, } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), @@ -2370,8 +2285,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), @@ -2426,8 +2339,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns: vi.fn(() => []), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, @@ -2515,8 +2426,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), listRuns: vi.fn(() => []), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a review-ready PR." })) } as any, @@ -2570,8 +2479,6 @@ describe("linearDispatcherService (file group)", () => { triggerWakeup, listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, @@ -2655,27 +2562,40 @@ describe("linearOutboundService (file group)", () => { logger: createLogger(), }); - await service.publishMissionStart({ + await service.publishWorkflowStatus({ issue: issueFixture, - missionId: "mission-1", - missionTitle: "Fix auth refresh", - templateId: "bug-fix", - routeReason: "Matched bug rule", - workerName: "Backend Dev", + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_target", + currentStep: "Launch worker run", + delegatedOwner: "Backend Dev", + workerRunId: "worker-run-1", + note: "Delegated the issue into a dedicated worker lane.", }); expect(createComment).toHaveBeenCalledTimes(1); - await service.publishMissionProgress({ + await service.publishWorkflowStatus({ issue: issueFixture, - missionId: "mission-1", - status: "in_progress", - stepSummary: "1/3 steps completed.", + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_target", + currentStep: "Wait", + delegatedOwner: "Backend Dev", + workerRunId: "worker-run-1", + note: "1/3 steps completed.", }); - await service.publishMissionProgress({ + await service.publishWorkflowStatus({ issue: issueFixture, - missionId: "mission-1", - status: "in_progress", - stepSummary: "1/3 steps completed.", + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_target", + currentStep: "Wait", + delegatedOwner: "Backend Dev", + workerRunId: "worker-run-1", + note: "1/3 steps completed.", }); expect(updateComment).toHaveBeenCalledTimes(1); @@ -2781,12 +2701,15 @@ describe("linearOutboundService (file group)", () => { logger: createLogger(), }); - await service.publishMissionStart({ + await service.publishWorkflowStatus({ issue: issueFixture, - missionId: "mission-1", - missionTitle: "Auth mission", - templateId: "bug-fix", - routeReason: "Matched bug", + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_target", + currentStep: "Launch worker run", + workerRunId: "worker-run-1", + note: "Matched bug.", }); await service.publishWorkflowCloseout({ @@ -2833,12 +2756,15 @@ describe("linearOutboundService (file group)", () => { logger: createLogger(), }); - await service.publishMissionStart({ + await service.publishWorkflowStatus({ issue: issueFixture, - missionId: "mission-1", - missionTitle: "Auth mission", - templateId: "bug-fix", - routeReason: "Matched bug", + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_target", + currentStep: "Launch worker run", + workerRunId: "worker-run-1", + note: "Matched bug.", }); await service.publishWorkflowCloseout({ @@ -2864,46 +2790,6 @@ describe("linearOutboundService (file group)", () => { db.close(); }); - it("keeps the mission closeout wrapper body shape stable", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-mission-closeout-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - const updateBodies: string[] = []; - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - updateComment: vi.fn(async (_commentId: string, body: string) => { - updateBodies.push(body); - }), - uploadAttachment: vi.fn(), - } as any, - logger: createLogger(), - }); - - await service.publishMissionStart({ - issue: issueFixture, - missionId: "mission-1", - missionTitle: "Auth mission", - templateId: "bug-fix", - routeReason: "Matched bug", - }); - - await service.publishMissionCloseout({ - issue: issueFixture, - missionId: "mission-1", - status: "completed", - summary: "Shipped", - artifactMode: "links", - }); - - const latest = updateBodies[updateBodies.length - 1] ?? ""; - expect(latest).toContain("- Mission: mission-1"); - expect(latest).not.toContain("- Target:"); - db.close(); - }); - it("renders comment templates for workflow status and closeout bodies", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-comment-")); const db = await openKvDb(path.join(root, "ade.db"), createLogger()); @@ -3058,7 +2944,6 @@ describe("linearWorkflowFileService (file group)", () => { expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); expect(loaded.settings.ctoLinearAssigneeName).toBe("CTO"); expect(loaded.workflows.map((workflow) => workflow.id)).toEqual([ - "cto-mission-autopilot", "cto-direct-employee-session", "cto-worker-run-autopilot", "cto-pr-fast-lane", @@ -3093,7 +2978,7 @@ describe("linearWorkflowFileService (file group)", () => { expect(saved.source).toBe("repo"); expect(saved.files.some((file) => file.kind === "settings")).toBe(true); - expect(saved.files.filter((file) => file.kind === "workflow")).toHaveLength(5); + expect(saved.files.filter((file) => file.kind === "workflow")).toHaveLength(4); expect(fs.existsSync(path.join(root, ".ade", "workflows", "linear", "_settings.yaml"))).toBe(true); }); @@ -3140,8 +3025,7 @@ describe("linearWorkflowFileService (file group)", () => { expect(loaded.intake.projectSlugs).toEqual(["acme-platform"]); expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); - expect(migrated?.target.type).toBe("mission"); - expect(migrated?.target.missionTemplate).toBe("fast-track"); + expect(migrated?.target.type).toBe("worker_run"); expect(migrated?.target.workerSelector).toEqual({ mode: "slug", value: "backend-hotfix" }); expect(migrated?.triggers.labels).toEqual(["bug"]); expect(migrated?.triggers.projectSlugs).toEqual(["acme-platform"]); diff --git a/apps/desktop/src/main/services/cto/linearTemplateService.ts b/apps/desktop/src/main/services/cto/linearTemplateService.ts index a6fa036a3..d80bcaa20 100644 --- a/apps/desktop/src/main/services/cto/linearTemplateService.ts +++ b/apps/desktop/src/main/services/cto/linearTemplateService.ts @@ -4,7 +4,7 @@ import YAML from "yaml"; import type { NormalizedLinearIssue } from "../../../shared/types"; import { isRecord, toOptionalString as asString } from "../shared/utils"; -export type LinearMissionTemplate = { +export type LinearTemplate = { id: string; name: string; promptTemplate: string; @@ -22,7 +22,7 @@ export type RenderTemplateResult = { metadata: Record; }; -const FALLBACK_TEMPLATE: LinearMissionTemplate = { +const FALLBACK_TEMPLATE: LinearTemplate = { id: "default", name: "Linear Intake", promptTemplate: [ @@ -56,7 +56,7 @@ function slugFromFilename(fileName: string): string { .replace(/^-+|-+$/g, "") || "template"; } -function parseTemplateFile(filePath: string): LinearMissionTemplate | null { +function parseTemplateFile(filePath: string): LinearTemplate | null { let parsed: unknown = null; try { parsed = YAML.parse(fs.readFileSync(filePath, "utf8")); @@ -120,14 +120,14 @@ function renderTemplateString(template: string, values: Record) export function createLinearTemplateService(args: { adeDir: string }) { const templatesDir = path.join(args.adeDir, "templates"); - const listTemplates = (): LinearMissionTemplate[] => { + const listTemplates = (): LinearTemplate[] => { if (!fs.existsSync(templatesDir)) return [FALLBACK_TEMPLATE]; const templates = fs .readdirSync(templatesDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) .map((entry) => parseTemplateFile(path.join(templatesDir, entry.name))) - .filter((entry): entry is LinearMissionTemplate => entry != null) + .filter((entry): entry is LinearTemplate => entry != null) .sort((a, b) => a.name.localeCompare(b.name)); const hasFallback = templates.some((entry) => entry.id === FALLBACK_TEMPLATE.id); @@ -135,7 +135,7 @@ export function createLinearTemplateService(args: { adeDir: string }) { return templates; }; - const getTemplate = (templateId: string | null | undefined): LinearMissionTemplate => { + const getTemplate = (templateId: string | null | undefined): LinearTemplate => { const all = listTemplates(); const key = String(templateId ?? "").trim().toLowerCase(); if (!key.length) return all[0] ?? FALLBACK_TEMPLATE; diff --git a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts index 95c4b6829..93f006750 100644 --- a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts +++ b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts @@ -77,8 +77,7 @@ function normalizeTarget(input: unknown): LinearWorkflowDefinition["target"] | n if (!isRecord(input)) return null; const targetType = input.type; if ( - targetType !== "mission" - && targetType !== "employee_session" + targetType !== "employee_session" && targetType !== "worker_run" && targetType !== "pr_resolution" && targetType !== "review_gate" @@ -97,7 +96,6 @@ function normalizeTarget(input: unknown): LinearWorkflowDefinition["target"] | n ? { employeeIdentityKey: input.employeeIdentityKey.trim() as LinearWorkflowDefinition["target"]["employeeIdentityKey"] } : {}), ...(typeof input.sessionTemplate === "string" ? { sessionTemplate: input.sessionTemplate } : {}), - ...(typeof input.missionTemplate === "string" ? { missionTemplate: input.missionTemplate } : {}), ...(input.executorKind === "cto" || input.executorKind === "employee" || input.executorKind === "worker" ? { executorKind: input.executorKind } : {}), @@ -240,20 +238,12 @@ function normalizeWorkflow(input: unknown, fallbackId: string): LinearWorkflowDe } function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): LinearWorkflowConfig { - const baseWorkflow = createWorkflowPreset("mission", { - id: "cto-mission-autopilot", - name: "CTO -> Mission autopilot", - description: "Default migrated mission-backed workflow.", - }); - baseWorkflow.target = { ...baseWorkflow.target, type: "mission", runMode: "autopilot", missionTemplate: "default" }; - if (!legacy) { const base = createDefaultLinearWorkflowConfig(); return { ...base, intake: normalizeIntake(null), workflows: [ - baseWorkflow, { ...createWorkflowPreset("employee_session", { id: "cto-direct-employee-session", @@ -323,7 +313,7 @@ function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): Linea const defaultProjectSlug = projects[0]?.slug ?? null; for (const [index, rule] of (legacy.autoDispatch?.rules ?? []).entries()) { - const targetType = rule.action === "auto" ? "mission" : "review_gate"; + const targetType = rule.action === "auto" ? "worker_run" : "review_gate"; const labelMatch = rule.match?.labels?.[0]?.trim().toLowerCase() ?? null; const labelRoute = labelMatch ? legacy.routing?.byLabel?.[labelMatch] : null; const projectWorker = defaultProjectSlug @@ -347,7 +337,7 @@ function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): Linea target: { type: targetType, runMode: targetType === "review_gate" ? "manual" : "autopilot", - missionTemplate: rule.template ?? "default", + sessionTemplate: rule.template ?? "default", ...(labelRoute ? { workerSelector: { mode: "slug", value: labelRoute } as const } : projectWorker @@ -358,7 +348,7 @@ function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): Linea { id: "launch", type: "launch_target", name: "Launch target" }, ...(targetType === "review_gate" ? [{ id: "review", type: "request_human_review", name: "Review gate" } as const] - : [{ id: "wait", type: "wait_for_target_status", name: "Wait for mission", targetStatus: "completed" } as const]), + : [{ id: "wait", type: "wait_for_target_status", name: "Wait for worker", targetStatus: "runtime_completed" } as const]), { id: "complete", type: "complete_issue", name: "Complete issue" }, ], closeout: { @@ -386,7 +376,7 @@ function migrateLegacyConfig(legacy: LinearSyncConfig | null | undefined): Linea } if (!workflows.length) { - workflows.push(baseWorkflow); + workflows.push(createWorkflowPreset("worker_run")); } return { diff --git a/apps/desktop/src/main/services/cto/workerTaskSessionService.ts b/apps/desktop/src/main/services/cto/workerTaskSessionService.ts index 696f53df7..94499d684 100644 --- a/apps/desktop/src/main/services/cto/workerTaskSessionService.ts +++ b/apps/desktop/src/main/services/cto/workerTaskSessionService.ts @@ -19,7 +19,6 @@ type EnsureTaskSessionArgs = { type DeriveTaskKeyArgs = { agentId: string; laneId?: string | null; - missionId?: string | null; workflowRunId?: string | null; linearIssueId?: string | null; chatSessionId?: string | null; @@ -75,7 +74,6 @@ export function createWorkerTaskSessionService(args: WorkerTaskSessionServiceArg const deriveTaskKey = (input: DeriveTaskKeyArgs): string => { const contextParts = [ `agent:${input.agentId.trim()}`, - input.missionId ? `mission:${input.missionId.trim()}` : "", input.workflowRunId ? `workflowRun:${input.workflowRunId.trim()}` : "", input.linearIssueId ? `linear:${input.linearIssueId.trim()}` : "", input.chatSessionId ? `chat:${input.chatSessionId.trim()}` : "", diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 70bb47a03..6a3485f2d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -99,8 +99,6 @@ import type { ReviewRunDetail, ReviewStartRunArgs, AdeActionRegistryEntry, - AddMissionArtifactArgs, - AddMissionInterventionArgs, ConflictProposal, ConflictExternalResolverRunSummary, ConflictProposalPreview, @@ -324,7 +322,6 @@ import type { ListOperationsArgs, ListOverlapsArgs, ListLanesArgs, - ListMissionsArgs, ListSessionsArgs, DeleteSessionArgs, ListTestRunsArgs, @@ -396,67 +393,11 @@ import type { TerminalSessionDetail, TerminalSessionSummary, UpdateSessionMetaArgs, - UpdateMissionArgs, - UpdateMissionStepArgs, TestRunSummary, TestSuiteDefinition, UpdateIntegrationProposalArgs, UpdateLaneAppearanceArgs, WriteTextAtomicArgs, - MissionDetail, - MissionIntervention, - MissionArtifact, - MissionStep, - MissionSummary, - PhaseCard, - PhaseProfile, - ListPhaseItemsArgs, - SavePhaseItemArgs, - DeletePhaseItemArgs, - ExportPhaseItemsArgs, - ExportPhaseItemsResult, - ImportPhaseItemsArgs, - ListPhaseProfilesArgs, - SavePhaseProfileArgs, - DeletePhaseProfileArgs, - ClonePhaseProfileArgs, - ExportPhaseProfileArgs, - ExportPhaseProfileResult, - ImportPhaseProfileArgs, - MissionPhaseConfiguration, - MissionDashboardSnapshot, - GetFullMissionViewArgs, - FullMissionViewResult, - MissionPreflightRequest, - MissionPreflightResult, - GetMissionRunViewArgs, - MissionRunView, - ResolveMissionInterventionArgs, - CreateMissionArgs, - ArchiveMissionArgs, - DeleteMissionArgs, - CancelOrchestratorRunArgs, - CleanupOrchestratorTeamResourcesArgs, - CleanupOrchestratorTeamResourcesResult, - CompleteOrchestratorAttemptArgs, - GetOrchestratorGateReportArgs, - GetOrchestratorRunGraphArgs, - HeartbeatOrchestratorClaimsArgs, - ListOrchestratorRunsArgs, - ListOrchestratorTimelineArgs, - OrchestratorAttempt, - OrchestratorExecutorKind, - OrchestratorGateReport, - OrchestratorRun, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorTimelineEvent, - PauseOrchestratorRunArgs, - ResumeOrchestratorRunArgs, - StartOrchestratorAttemptArgs, - StartOrchestratorRunFromMissionArgs, - StartOrchestratorRunArgs, - TickOrchestratorRunArgs, AiFeatureKey, AiApiKeyVerificationResult, AiConfig, @@ -481,60 +422,6 @@ import type { CtoListSessionLogsArgs, CtoSnapshot, CtoSessionLogEntry, - GetOrchestratorWorkerStatesArgs, - OrchestratorWorkerState, - StartMissionRunWithAIArgs, - StartMissionRunWithAIResult, - SteerMissionArgs, - SteerMissionResult, - GetModelCapabilitiesResult, - GetTeamMembersArgs, - GetTeamRuntimeStateArgs, - FinalizeRunArgs, - FinalizeRunResult, - OrchestratorTeamMember, - OrchestratorTeamRuntimeState, - GetMissionMetricsArgs, - GetOrchestratorContextCheckpointArgs, - OrchestratorChatMessage, - OrchestratorChatThread, - OrchestratorContextCheckpoint, - OrchestratorLaneDecision, - OrchestratorWorkerDigest, - SendOrchestratorChatArgs, - GetOrchestratorChatArgs, - ListOrchestratorChatThreadsArgs, - GetOrchestratorThreadMessagesArgs, - SendOrchestratorThreadMessageArgs, - GetOrchestratorWorkerDigestArgs, - ListOrchestratorWorkerDigestsArgs, - ListOrchestratorLaneDecisionsArgs, - ListOrchestratorArtifactsArgs, - ListOrchestratorWorkerCheckpointsArgs, - MissionMetricsConfig, - MissionMetricSample, - SetMissionMetricsConfigArgs, - ExecutionPlanPreview, - GetMissionStateDocumentArgs, - MissionStateDocument, - OrchestratorArtifact, - OrchestratorWorkerCheckpoint, - GetOrchestratorPromptInspectorArgs, - GetPlanningPromptPreviewArgs, - OrchestratorPromptInspector, - GetMissionLogsArgs, - GetMissionLogsResult, - ExportMissionLogsArgs, - ExportMissionLogsResult, - GetMissionBudgetTelemetryArgs, - GetMissionBudgetStatusArgs, - MissionBudgetSnapshot, - MissionBudgetTelemetrySnapshot, - SendAgentMessageArgs, - GetGlobalChatArgs, - DeliverMessageArgs, - GetActiveAgentsArgs, - ActiveAgentInfo, AgentIdentity, AgentSessionLogEntry, AgentConfigRevision, @@ -677,13 +564,7 @@ import type { createAutomationIngressService } from "../automations/automationIn import type { createGithubPollingService } from "../automations/githubPollingService"; import { ADE_ACTION_ALLOWLIST, getAdeActionDomainServices, listAllowedAdeActionNames } from "../adeActions/registry"; import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; -import { type createMissionService } from "../missions/missionService"; -import type { createMissionPreflightService } from "../missions/missionPreflightService"; -import type { createMissionBudgetService } from "../orchestrator/missionBudgetService"; -import type { createOrchestratorService } from "../orchestrator/orchestratorService"; -import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; -import { readCoordinatorCheckpoint } from "../orchestrator/missionStateDoc"; import type { createCtoStateService } from "../cto/ctoStateService"; import type { createWorkerAgentService } from "../cto/workerAgentService"; import type { createWorkerRevisionService } from "../cto/workerRevisionService"; @@ -779,11 +660,6 @@ export type AppContext = { automationPlannerService: ReturnType; automationIngressService?: ReturnType | null; githubPollingService?: ReturnType | null; - missionService: ReturnType; - missionPreflightService: ReturnType; - orchestratorService: ReturnType; - missionBudgetService: ReturnType; - aiOrchestratorService: ReturnType; projectConfigService: ReturnType; processService: ReturnType; testService: ReturnType; @@ -844,8 +720,6 @@ const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ "commit_messages", "pr_descriptions", "terminal_summaries", - "mission_planning", - "orchestrator", "initial_context" ]; @@ -941,187 +815,6 @@ function getUnavailableAiStatus(): AiSettingsStatus { }; } - -function normalizeAutopilotExecutor(value: unknown): OrchestratorExecutorKind { - const raw = typeof value === "string" ? value.trim() : ""; - if ( - raw === "shell" - || raw === "manual" - || raw === "opencode" - || raw === "codex" - || raw === "claude" - || raw === "cursor" - || raw === "droid" - ) return raw; - return "opencode"; -} - -const RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT = 12; -const PAYLOAD_DOC_REF_TRANSPORT_LIMIT = 12; -const RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT = 5; -const CHAT_TOOL_RESULT_STRING_LIMIT = 1_200; -const CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT = 5; -const CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT = 12; - -function isAdeInternalDocPath(value: unknown): boolean { - if (typeof value !== "string") return false; - const normalized = value.replace(/\\/g, "/"); - return normalized === ".ade" || normalized.startsWith(".ade/") || normalized.includes("/.ade/"); -} - -function compactRuntimeCursorForTransport(value: unknown): unknown { - if (!isRecord(value)) return value; - const rawDocs = Array.isArray(value.docs) ? value.docs : []; - const docs = rawDocs - .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) - .slice(0, RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT) - .map((entry) => { - if (!isRecord(entry)) return entry; - return { - path: typeof entry.path === "string" ? entry.path : "", - bytes: typeof entry.bytes === "number" ? entry.bytes : 0, - sha256: typeof entry.sha256 === "string" ? entry.sha256 : "", - truncated: entry.truncated === true, - mode: typeof entry.mode === "string" ? entry.mode : undefined, - }; - }); - return { - ...value, - docs, - docsOmittedCount: Math.max(0, rawDocs.length - docs.length), - }; -} - -function compactDocRefsArrayForTransport(rawDocs: unknown[], limit: number): unknown[] { - return rawDocs - .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) - .slice(0, limit); -} - -function compactPayloadForTransport(payload: Record | null): Record | null { - if (!payload) return payload; - const next: Record = { ...payload }; - if (Array.isArray(next.docsRefs)) { - const rawDocsRefs = next.docsRefs; - const docsRefs = compactDocRefsArrayForTransport(rawDocsRefs, PAYLOAD_DOC_REF_TRANSPORT_LIMIT); - next.docsRefs = docsRefs; - next.docsRefsOmittedCount = Math.max(0, rawDocsRefs.length - docsRefs.length); - } - return next; -} - -function compactChatToolValueForTransport(value: unknown): unknown { - if (value == null || typeof value === "boolean" || typeof value === "number") return value; - if (typeof value === "string") { - if (value.length <= CHAT_TOOL_RESULT_STRING_LIMIT) return value; - return { - preview: value.slice(0, CHAT_TOOL_RESULT_STRING_LIMIT), - omittedChars: value.length - CHAT_TOOL_RESULT_STRING_LIMIT, - }; - } - if (Array.isArray(value)) { - return { - type: "array", - length: value.length, - preview: value - .slice(0, CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT) - .map((entry) => compactChatToolValueForTransport(entry)), - omittedItems: Math.max(0, value.length - CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT), - }; - } - if (!isRecord(value)) return value; - - const safeKeys = [ - "ok", - "status", - "outcome", - "summary", - "message", - "error", - "workerId", - "stepId", - "stepKey", - "runId", - "missionId", - "filesChanged", - "testsRun", - "artifacts", - ]; - const next: Record = {}; - for (const key of safeKeys) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - next[key] = compactChatToolValueForTransport(value[key]); - } - } - const keys = Object.keys(value); - next.__adeTransportCompact = true; - next.keys = keys.slice(0, CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); - next.omittedKeys = Math.max(0, keys.length - CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); - return next; -} - -function compactChatMessageMetadataForTransport(metadata: OrchestratorChatMessage["metadata"]): OrchestratorChatMessage["metadata"] { - if (!isRecord(metadata)) return metadata; - const structuredStream = isRecord(metadata.structuredStream) ? metadata.structuredStream : null; - if (!structuredStream) return metadata; - const nextStructured = { ...structuredStream }; - if (Object.prototype.hasOwnProperty.call(nextStructured, "result")) { - nextStructured.result = compactChatToolValueForTransport(nextStructured.result); - } - return { - ...metadata, - structuredStream: nextStructured, - }; -} - -function compactChatMessageForTransport(message: OrchestratorChatMessage): OrchestratorChatMessage { - return { - ...message, - metadata: compactChatMessageMetadataForTransport(message.metadata), - }; -} - -function compactRunMetadataForTransport(metadata: OrchestratorRun["metadata"]): OrchestratorRun["metadata"] { - if (!isRecord(metadata)) return metadata; - const next: Record = { ...metadata }; - if (isRecord(next.runtimeCursor)) { - next.runtimeCursor = compactRuntimeCursorForTransport(next.runtimeCursor); - } - return next; -} - -function compactRunForTransport(run: OrchestratorRun): OrchestratorRun { - return { - ...run, - metadata: compactRunMetadataForTransport(run.metadata), - }; -} - -function compactRunGraphForTransport(graph: OrchestratorRunGraph): OrchestratorRunGraph { - return { - ...graph, - run: compactRunForTransport(graph.run), - contextSnapshots: graph.contextSnapshots - .slice(0, RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT) - .map((snapshot) => ({ - ...snapshot, - cursor: compactRuntimeCursorForTransport(snapshot.cursor) as typeof snapshot.cursor, - })), - handoffs: graph.handoffs.map((handoff) => ({ - ...handoff, - payload: compactPayloadForTransport(handoff.payload) ?? {}, - })), - timeline: graph.timeline.map((event) => ({ - ...event, - detail: compactPayloadForTransport(event.detail), - })), - runtimeEvents: graph.runtimeEvents?.map((event) => ({ - ...event, - payload: compactPayloadForTransport(event.payload), - })), - }; -} - /** * Strict resolver for identity-pinned sessions (CTO + worker agents). Requires * an actual primary lane and never slips a foreign lane through via a @@ -4783,661 +4476,6 @@ export function registerIpc({ return entries; }); - ipcMain.handle(IPC.missionsList, async (_event, arg: ListMissionsArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.missionService.list(arg); - }); - - ipcMain.handle(IPC.missionsGet, async (_event, arg: { missionId: string }): Promise => { - const ctx = getCtx(); - return ctx.missionService.get(arg?.missionId ?? ""); - }); - - ipcMain.handle(IPC.missionsListPhaseItems, async (_event, arg: ListPhaseItemsArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.missionService.listPhaseItems(arg); - }); - - ipcMain.handle(IPC.missionsSavePhaseItem, async (_event, arg: SavePhaseItemArgs): Promise => { - const ctx = getCtx(); - return ctx.missionService.savePhaseItem(arg); - }); - - ipcMain.handle(IPC.missionsDeletePhaseItem, async (_event, arg: DeletePhaseItemArgs): Promise => { - const ctx = getCtx(); - ctx.missionService.deletePhaseItem(arg); - }); - - ipcMain.handle(IPC.missionsImportPhaseItems, async (_event, arg: ImportPhaseItemsArgs): Promise => { - const ctx = getCtx(); - return ctx.missionService.importPhaseItems(arg); - }); - - ipcMain.handle(IPC.missionsExportPhaseItems, async (_event, arg: ExportPhaseItemsArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.missionService.exportPhaseItems(arg); - }); - - ipcMain.handle(IPC.missionsListPhaseProfiles, async (_event, arg: ListPhaseProfilesArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.missionService.listPhaseProfiles(arg); - }); - - ipcMain.handle(IPC.missionsSavePhaseProfile, async (_event, arg: SavePhaseProfileArgs): Promise => { - const ctx = getCtx(); - return ctx.missionService.savePhaseProfile(arg); - }); - - ipcMain.handle(IPC.missionsDeletePhaseProfile, async (_event, arg: DeletePhaseProfileArgs): Promise => { - const ctx = getCtx(); - ctx.missionService.deletePhaseProfile(arg); - }); - - ipcMain.handle(IPC.missionsClonePhaseProfile, async (_event, arg: ClonePhaseProfileArgs): Promise => { - const ctx = getCtx(); - return ctx.missionService.clonePhaseProfile(arg); - }); - - ipcMain.handle(IPC.missionsExportPhaseProfile, async (_event, arg: ExportPhaseProfileArgs): Promise => { - const ctx = getCtx(); - return ctx.missionService.exportPhaseProfile(arg); - }); - - ipcMain.handle(IPC.missionsImportPhaseProfile, async (_event, arg: ImportPhaseProfileArgs): Promise => { - const ctx = getCtx(); - return ctx.missionService.importPhaseProfile(arg); - }); - - ipcMain.handle(IPC.missionsGetPhaseConfiguration, async (_event, arg: { missionId: string }): Promise => { - const ctx = getCtx(); - return ctx.missionService.getPhaseConfiguration(arg?.missionId ?? ""); - }); - - ipcMain.handle(IPC.missionsGetDashboard, async (): Promise => { - const ctx = getCtx(); - return ctx.missionService.getDashboard(); - }); - - ipcMain.handle( - IPC.missionsGetFullMissionView, - async (_event, arg: GetFullMissionViewArgs): Promise => { - const ctx = getCtx(); - const missionId = typeof arg?.missionId === "string" ? arg.missionId.trim() : ""; - if (!missionId) return { mission: null, runGraph: null, artifacts: [], checkpoints: [], dashboard: null }; - - let dashboard: MissionDashboardSnapshot | null = null; - try { - dashboard = ctx.missionService.getDashboard(); - } catch { - /* best-effort */ - } - - const mission = await ctx.missionService.get(missionId); - - let runGraph: OrchestratorRunGraph | null = null; - let artifacts: OrchestratorArtifact[] = []; - let checkpoints: OrchestratorWorkerCheckpoint[] = []; - - const runs = await ctx.orchestratorService.listRuns({ missionId, limit: 20 }); - const activeStatuses = new Set(["active", "bootstrapping", "queued", "paused"]); - const preferredRun = runs.find((entry) => activeStatuses.has(entry.status)) ?? runs[0]; - if (preferredRun) { - const [graph, arts, cps] = await Promise.all([ - ctx.orchestratorService.getRunGraph({ runId: preferredRun.id, timelineLimit: 120 }), - Promise.resolve().then(() => ctx.aiOrchestratorService.listArtifacts({ missionId, runId: preferredRun.id })).catch(() => []), - Promise.resolve().then(() => ctx.aiOrchestratorService.listWorkerCheckpoints({ missionId, runId: preferredRun.id })).catch(() => []), - ]); - runGraph = compactRunGraphForTransport(graph); - artifacts = Array.isArray(arts) ? arts : []; - checkpoints = Array.isArray(cps) ? cps : []; - } - - return { mission, runGraph, artifacts, checkpoints, dashboard }; - }, - ); - - ipcMain.handle(IPC.missionsPreflight, async (_event, arg: MissionPreflightRequest): Promise => { - const ctx = getCtx(); - return await ctx.missionPreflightService.runPreflight(arg); - }); - - ipcMain.handle(IPC.missionsGetRunView, async (_event, arg: GetMissionRunViewArgs): Promise => { - const ctx = getCtx(); - return await ctx.aiOrchestratorService.getRunView(arg); - }); - - ipcMain.handle(IPC.missionsCreate, async (_event, arg: CreateMissionArgs): Promise => { - const ctx = getCtx(); - const prompt = typeof arg?.prompt === "string" ? arg.prompt.trim() : ""; - if (!prompt.length) throw new Error("Mission prompt is required."); - const plannerEngine = arg?.plannerEngine ?? "auto"; - const autostart = arg?.autostart !== false; - const runMode = arg?.launchMode === "manual" ? "manual" : "autopilot"; - const defaultExecutorKind: OrchestratorExecutorKind = runMode === "manual" - ? "manual" - : normalizeAutopilotExecutor(arg?.autopilotExecutor ?? "opencode"); - - // Fast-path for autostart missions: create immediately and launch in the background - // so renderer IPC does not block on planning/launch work. - if (autostart) { - const created = ctx.missionService.create({ - ...arg, - launchMode: runMode, - autostart: true, - autopilotExecutor: defaultExecutorKind - }); - - void (async () => { - try { - await ctx.aiOrchestratorService.startMissionRun({ - missionId: created.id, - runMode, - autopilotOwnerId: "missions-autopilot", - defaultExecutorKind, - defaultRetryLimit: 1, - metadata: { - launchSource: "missions.create.fast_path", - plannerEngineRequested: plannerEngine, - plannerExecutorPolicy: "codex" - } - }); - } catch (error) { - const message = getErrorMessage(error); - const errorRecord = error as unknown as Record; - const launchFailure = error instanceof Error && isRecord(errorRecord.missionLaunchFailure) - ? (errorRecord.missionLaunchFailure as Record) - : null; - ctx.logger.warn("missions.autostart_failed", { - missionId: created.id, - runMode, - defaultExecutorKind, - error: message, - failureStage: typeof launchFailure?.failureStage === "string" ? launchFailure.failureStage : null, - runId: typeof launchFailure?.runId === "string" ? launchFailure.runId : null, - rootErrorStack: typeof launchFailure?.rootErrorStack === "string" ? launchFailure.rootErrorStack : null, - }); - if (!launchFailure) { - try { - ctx.missionService.addIntervention({ - missionId: created.id, - interventionType: "policy_block", - title: "Mission launch requires action", - body: `Automatic run launch failed: ${message}`, - requestedAction: "Review planner/runtime configuration and retry the blocked step." - }); - } catch { - // ignore best-effort intervention creation - } - } - } - })(); - - const detail = ctx.missionService.get(created.id); - if (detail) return detail; - return created; - } - - // Pre-mission planner pipeline retired — coordinator builds the DAG at runtime. - // Simply create the mission with empty steps (non-autostart path). - const created = ctx.missionService.create({ - ...arg, - launchMode: "manual", - autostart: false, - }); - const detail = ctx.missionService.get(created.id); - if (detail) return detail; - return created; - }); - - ipcMain.handle(IPC.missionsUpdate, async (_event, arg: UpdateMissionArgs): Promise => { - const ctx = getCtx(); - const updated = ctx.missionService.update(arg); - return updated; - }); - - ipcMain.handle(IPC.missionsArchive, async (_event, arg: ArchiveMissionArgs): Promise => { - const ctx = getCtx(); - ctx.missionService.archive(arg); - }); - - ipcMain.handle(IPC.missionsDelete, async (_event, arg: DeleteMissionArgs): Promise => { - const ctx = getCtx(); - ctx.missionService.delete(arg); - }); - - ipcMain.handle(IPC.missionsUpdateStep, async (_event, arg: UpdateMissionStepArgs): Promise => { - const ctx = getCtx(); - const updated = ctx.missionService.updateStep(arg); - return updated; - }); - - ipcMain.handle(IPC.missionsAddArtifact, async (_event, arg: AddMissionArtifactArgs): Promise => { - const ctx = getCtx(); - const artifact = ctx.missionService.addArtifact(arg); - return artifact; - }); - - ipcMain.handle( - IPC.missionsAddIntervention, - async (_event, arg: AddMissionInterventionArgs): Promise => { - const ctx = getCtx(); - const intervention = ctx.missionService.addIntervention(arg); - return intervention; - } - ); - - ipcMain.handle( - IPC.missionsResolveIntervention, - async (_event, arg: ResolveMissionInterventionArgs): Promise => { - const ctx = getCtx(); - const intervention = ctx.missionService.resolveIntervention(arg); - return intervention; - } - ); - - ipcMain.handle(IPC.orchestratorListRuns, async (_event, arg: ListOrchestratorRunsArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.listRuns(arg).map(compactRunForTransport); - }); - - ipcMain.handle(IPC.orchestratorGetRunGraph, async (_event, arg: GetOrchestratorRunGraphArgs): Promise => { - const ctx = getCtx(); - return compactRunGraphForTransport(ctx.orchestratorService.getRunGraph(arg)); - }); - - ipcMain.handle( - IPC.orchestratorStartRun, - async (_event, arg: StartOrchestratorRunArgs): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => { - const ctx = getCtx(); - const started = ctx.orchestratorService.startRun(arg); - return { ...started, run: compactRunForTransport(started.run) }; - } - ); - - ipcMain.handle( - IPC.orchestratorStartRunFromMission, - async (_event, arg: StartOrchestratorRunFromMissionArgs): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => { - const ctx = getCtx(); - const started = await ctx.aiOrchestratorService.startMissionRun({ - missionId: arg.missionId, - runMode: arg.runMode, - autopilotOwnerId: arg.autopilotOwnerId, - defaultExecutorKind: arg.defaultExecutorKind, - defaultRetryLimit: arg.defaultRetryLimit, - metadata: arg.metadata ?? null, - plannerProvider: arg.plannerProvider ?? undefined - }); - if (!started.started) { - throw new Error("Mission run did not produce a runnable execution."); - } - return { ...started.started, run: compactRunForTransport(started.started.run) }; - } - ); - - ipcMain.handle( - IPC.orchestratorStartAttempt, - async (_event, arg: StartOrchestratorAttemptArgs): Promise => { - const ctx = getCtx(); - return await ctx.orchestratorService.startAttempt(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorCompleteAttempt, - async (_event, arg: CompleteOrchestratorAttemptArgs): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.completeAttempt(arg); - } - ); - - ipcMain.handle(IPC.orchestratorTickRun, async (_event, arg: TickOrchestratorRunArgs): Promise => { - const ctx = getCtx(); - return compactRunForTransport(ctx.orchestratorService.tick(arg)); - }); - - ipcMain.handle(IPC.orchestratorPauseRun, async (_event, arg: PauseOrchestratorRunArgs): Promise => { - const ctx = getCtx(); - return compactRunForTransport(ctx.orchestratorService.pauseRun({ - runId: arg.runId, - reason: arg.reason ?? "Paused from Missions UI.", - })); - }); - - ipcMain.handle(IPC.orchestratorResumeRun, async (_event, arg: ResumeOrchestratorRunArgs): Promise => { - const ctx = getCtx(); - return compactRunForTransport(ctx.aiOrchestratorService.resumeRun(arg)); - }); - - ipcMain.handle(IPC.orchestratorCancelRun, async (_event, arg: CancelOrchestratorRunArgs): Promise => { - const ctx = getCtx(); - try { - await ctx.aiOrchestratorService.cancelRunGracefully(arg); - } catch (error) { - ctx.logger.warn("ipc.orchestrator_cancel_graceful_failed", { - runId: arg?.runId ?? null, - error: getErrorMessage(error) - }); - ctx.orchestratorService.cancelRun(arg); - } - const run = ctx.orchestratorService.listRuns({ limit: 1_000 }).find((entry) => entry.id === arg.runId); - if (!run) throw new Error(`Run not found after cancellation: ${arg.runId}`); - return compactRunForTransport(run); - }); - - ipcMain.handle( - IPC.orchestratorCleanupTeamResources, - async (_event, arg: CleanupOrchestratorTeamResourcesArgs): Promise => { - const ctx = getCtx(); - return await ctx.aiOrchestratorService.cleanupTeamResources(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorHeartbeatClaims, - async (_event, arg: HeartbeatOrchestratorClaimsArgs): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.heartbeatClaims(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorListTimeline, - async (_event, arg: ListOrchestratorTimelineArgs): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.listTimeline(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetMissionLogs, - async (_event, arg: GetMissionLogsArgs): Promise => { - const ctx = getCtx(); - return await ctx.aiOrchestratorService.getMissionLogs(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorExportMissionLogs, - async (_event, arg: ExportMissionLogsArgs): Promise => { - const ctx = getCtx(); - return await ctx.aiOrchestratorService.exportMissionLogs(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetGateReport, - async (_event, arg: GetOrchestratorGateReportArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.getLatestGateReport(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetWorkerStates, - async (_event, arg: GetOrchestratorWorkerStatesArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getWorkerStates(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorStartMissionRun, - async (_event, arg: StartMissionRunWithAIArgs): Promise => { - const ctx = getCtx(); - const result = await ctx.aiOrchestratorService.startMissionRun(arg); - return result.started - ? { ...result, started: { ...result.started, run: compactRunForTransport(result.started.run) } } - : result; - } - ); - - ipcMain.handle( - IPC.orchestratorSteerMission, - async (_event, arg: SteerMissionArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.steerMission(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetModelCapabilities, - async (): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getModelCapabilities(); - } - ); - - ipcMain.handle( - IPC.orchestratorGetTeamMembers, - async (_event, arg: GetTeamMembersArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getTeamMembers({ runId: arg.runId }); - } - ); - - ipcMain.handle( - IPC.orchestratorGetTeamRuntimeState, - async (_event, arg: GetTeamRuntimeStateArgs): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.getRunState(arg.runId); - } - ); - - ipcMain.handle( - IPC.orchestratorFinalizeRun, - async (_event, arg: FinalizeRunArgs): Promise => { - const ctx = getCtx(); - return ctx.orchestratorService.finalizeRun(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorSendChat, - async (_event, arg: SendOrchestratorChatArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.sendChat(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetChat, - async (_event, arg: GetOrchestratorChatArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getChat(arg).map(compactChatMessageForTransport); - } - ); - - ipcMain.handle( - IPC.orchestratorListChatThreads, - async (_event, arg: ListOrchestratorChatThreadsArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.listChatThreads(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetThreadMessages, - async (_event, arg: GetOrchestratorThreadMessagesArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getThreadMessages(arg).map(compactChatMessageForTransport); - } - ); - - ipcMain.handle( - IPC.orchestratorSendThreadMessage, - async (_event, arg: SendOrchestratorThreadMessageArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.sendThreadMessage(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetWorkerDigest, - async (_event, arg: GetOrchestratorWorkerDigestArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getWorkerDigest(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorListWorkerDigests, - async (_event, arg: ListOrchestratorWorkerDigestsArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.listWorkerDigests(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetContextCheckpoint, - async (_event, arg: GetOrchestratorContextCheckpointArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getContextCheckpoint(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorListLaneDecisions, - async (_event, arg: ListOrchestratorLaneDecisionsArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.listLaneDecisions(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorListArtifacts, - async (_event, arg: ListOrchestratorArtifactsArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.listArtifacts(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorListWorkerCheckpoints, - async (_event, arg: ListOrchestratorWorkerCheckpointsArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.listWorkerCheckpoints(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetPromptInspector, - async (_event, arg: GetOrchestratorPromptInspectorArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getPromptInspector(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetPlanningPromptPreview, - async (_event, arg: GetPlanningPromptPreviewArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getPlanningPromptPreview(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetMissionMetrics, - async (_event, arg: GetMissionMetricsArgs): Promise<{ config: MissionMetricsConfig | null; samples: MissionMetricSample[] }> => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getMissionMetrics(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetMissionBudgetStatus, - async (_event, arg: GetMissionBudgetStatusArgs): Promise => { - const ctx = getCtx(); - return await ctx.missionBudgetService.getMissionBudgetStatus(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetMissionBudgetTelemetry, - async (_event, arg: GetMissionBudgetTelemetryArgs): Promise => { - const ctx = getCtx(); - return ctx.missionBudgetService.getMissionBudgetTelemetry(arg ?? {}); - } - ); - - ipcMain.handle( - IPC.orchestratorSetMissionMetricsConfig, - async (_event, arg: SetMissionMetricsConfigArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.setMissionMetricsConfig(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetExecutionPlanPreview, - async (_event, arg: { runId: string }): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getExecutionPlanPreview(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetMissionStateDocument, - async (_event, arg: GetMissionStateDocumentArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getMissionStateDocument(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetCheckpointStatus, - async ( - _event, - arg: { runId: string } - ): Promise<{ savedAt: string; turnCount: number; compactionCount: number } | null> => { - const ctx = getCtx(); - const runId = arg?.runId?.trim(); - if (!runId) return null; - const checkpoint = await readCoordinatorCheckpoint(ctx.project.rootPath, runId); - if (!checkpoint) return null; - return { - savedAt: checkpoint.savedAt, - turnCount: checkpoint.turnCount, - compactionCount: checkpoint.compactionCount - }; - } - ); - - ipcMain.handle( - IPC.orchestratorSendAgentMessage, - async (_event, arg: SendAgentMessageArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.sendAgentMessage(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetGlobalChat, - async (_event, arg: GetGlobalChatArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getGlobalChat(arg).map(compactChatMessageForTransport); - } - ); - - ipcMain.handle( - IPC.orchestratorDeliverMessage, - async (_event, arg: DeliverMessageArgs): Promise<{ delivered: boolean; method: string }> => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.deliverMessageToAgent(arg); - } - ); - - ipcMain.handle( - IPC.orchestratorGetActiveAgents, - async (_event, arg: GetActiveAgentsArgs): Promise => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getActiveAgents(arg); - } - ); - - ipcMain.handle(IPC.getAggregatedUsage, (_e, arg) => { - const ctx = getCtx(); - return ctx.aiOrchestratorService.getAggregatedUsage(arg ?? {}); - }); // ── Usage tracking + budget cap IPC ────────────────────────── ipcMain.handle(IPC.usageGetSnapshot, async (): Promise => { @@ -6753,9 +5791,6 @@ export function registerIpc({ if (resumeCtx.provider !== "codex") { throw new Error("Open-in-CLI is only supported for Codex sessions"); } - if (resumeCtx.isMission) { - throw new Error("Mission sessions cannot be resumed in Codex CLI (ephemeral CODEX_HOME)"); - } const resolved = resolveCodexExecutable(); const strategy = await detectCodexResumeStrategy(resolved.path); const argv = buildResumeArgv(strategy, resumeCtx.threadId); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 56b77b41a..ad57a50cb 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -787,7 +787,7 @@ describe("laneService create", () => { attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - ["lane-main", "proj-create-primary-parent", "Primary", null, "primary", "main", "missions-overhaul", repoRoot, null, 1, null, null, null, null, "active", now, null], + ["lane-main", "proj-create-primary-parent", "Primary", null, "primary", "main", "feature-overhaul", repoRoot, null, 1, null, null, null, null, "active", now, null], ); vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { @@ -870,7 +870,7 @@ describe("laneService list repairs", () => { attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - ["lane-main", "proj-repair-root-base", "Main", null, "primary", "main", "missions-overhaul", repoRoot, null, 1, null, null, null, null, "active", now, null], + ["lane-main", "proj-repair-root-base", "Main", null, "primary", "main", "feature-overhaul", repoRoot, null, 1, null, null, null, null, "active", now, null], ); db.run( ` @@ -918,7 +918,7 @@ describe("laneService list repairs", () => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { - return { exitCode: 0, stdout: "missions-overhaul\n", stderr: "" }; + return { exitCode: 0, stdout: "feature-overhaul\n", stderr: "" }; } throw new Error(`Unexpected git call: ${args.join(" ")}`); }); @@ -2274,80 +2274,13 @@ describe("laneService reparent", () => { }); }); -describe("laneService missionId and laneRole", () => { +describe("laneService createChild", () => { beforeEach(() => { vi.mocked(getHeadSha).mockReset(); vi.mocked(runGit).mockReset(); vi.mocked(runGitOrThrow).mockReset(); }); - it("createChild persists missionId and laneRole on the created lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-mission-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - await seedProjectAndStack(db, { projectId: "proj-child-mission", repoRoot }); - - const parentWorktreePath = path.join(repoRoot, "parent"); - - vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { - if (cwd === parentWorktreePath) return "sha-parent-head"; - return "sha-generic"; - }); - - vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { - if (args[0] === "worktree" && args[1] === "add") { - return { exitCode: 0, stdout: "", stderr: "" } as any; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - vi.mocked(runGit).mockImplementation(async (args: string[]) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "push" && args[1] === "-u") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "status" && args[1] === "--porcelain=v1") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { - return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - // Insert a mission row so the FK on lanes.mission_id is satisfied - const now = "2026-03-11T12:00:00.000Z"; - db.run( - `insert into missions(id, project_id, title, prompt, status, priority, execution_mode, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["mission-abc", "proj-child-mission", "Test mission", "do something", "pending", "normal", "local", now, now], - ); - - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-child-mission", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - - const lane = await service.createChild({ - parentLaneId: "lane-parent", - name: "Mission worker lane", - missionId: "mission-abc", - laneRole: "worker", - }); - - expect(lane.missionId).toBe("mission-abc"); - expect(lane.laneRole).toBe("worker"); - }); - it("createChild links existing app dependency installs into the worktree", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-deps-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); @@ -2390,7 +2323,7 @@ describe("laneService missionId and laneRole", () => { await service.createChild({ parentLaneId: "lane-parent", - name: "Mission worker lane", + name: "Worker lane", }); expect(createdWorktreePath).toBeTruthy(); @@ -2398,63 +2331,6 @@ describe("laneService missionId and laneRole", () => { expect(linked.isSymbolicLink()).toBe(true); }); - it("createChild defaults missionId and laneRole to null when omitted", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-no-mission-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - await seedProjectAndStack(db, { projectId: "proj-child-no-mission", repoRoot }); - - const parentWorktreePath = path.join(repoRoot, "parent"); - - vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { - if (cwd === parentWorktreePath) return "sha-parent-head"; - return "sha-generic"; - }); - - vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { - if (args[0] === "worktree" && args[1] === "add") { - return { exitCode: 0, stdout: "", stderr: "" } as any; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - vi.mocked(runGit).mockImplementation(async (args: string[]) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "push" && args[1] === "-u") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "status" && args[1] === "--porcelain=v1") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { - return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-child-no-mission", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - - const lane = await service.createChild({ - parentLaneId: "lane-parent", - name: "Plain child lane", - }); - - expect(lane.missionId).toBeNull(); - expect(lane.laneRole).toBeNull(); - }); - it("createChild from primary anchors the new lane to the project default base", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-primary-default-base-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); @@ -2608,214 +2484,6 @@ describe("laneService missionId and laneRole", () => { expect(lane.parentLaneId).toBe("lane-parent"); }); - it("setMissionOwnership updates missionId and laneRole on an existing lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-set-ownership-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - await seedProjectAndStack(db, { projectId: "proj-set-ownership", repoRoot }); - - vi.mocked(runGit).mockImplementation(async (args: string[]) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { - return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - // Insert a mission row so the FK on lanes.mission_id is satisfied - const now = "2026-03-11T12:00:00.000Z"; - db.run( - `insert into missions(id, project_id, title, prompt, status, priority, execution_mode, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["mission-xyz", "proj-set-ownership", "Test mission", "do something", "pending", "normal", "local", now, now], - ); - - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-set-ownership", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - - // Verify initially null - const before = (await service.list({ includeStatus: false })).find((l) => l.id === "lane-parent"); - expect(before?.missionId).toBeNull(); - expect(before?.laneRole).toBeNull(); - - // Set mission ownership - service.setMissionOwnership({ - laneId: "lane-parent", - missionId: "mission-xyz", - laneRole: "mission_root", - }); - - const after = (await service.list({ includeStatus: false })).find((l) => l.id === "lane-parent"); - expect(after?.missionId).toBe("mission-xyz"); - expect(after?.laneRole).toBe("mission_root"); - }); - - it("setMissionOwnership clears fields when passed null", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-clear-ownership-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - await seedProjectAndStack(db, { projectId: "proj-clear-ownership", repoRoot }); - - // Insert a mission row so the FK on lanes.mission_id is satisfied - const now = "2026-03-11T12:00:00.000Z"; - db.run( - `insert into missions(id, project_id, title, prompt, status, priority, execution_mode, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["mission-existing", "proj-clear-ownership", "Test mission", "do something", "pending", "normal", "local", now, now], - ); - - // Manually set mission_id/lane_role in DB first - db.run("update lanes set mission_id = ?, lane_role = ? where id = ?", ["mission-existing", "worker", "lane-parent"]); - - vi.mocked(runGit).mockImplementation(async (args: string[]) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { - return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-clear-ownership", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - - // Verify pre-existing values - const before = (await service.list({ includeStatus: false })).find((l) => l.id === "lane-parent"); - expect(before?.missionId).toBe("mission-existing"); - expect(before?.laneRole).toBe("worker"); - - // Clear by setting null - service.setMissionOwnership({ - laneId: "lane-parent", - missionId: null, - laneRole: null, - }); - - const after = (await service.list({ includeStatus: false })).find((l) => l.id === "lane-parent"); - expect(after?.missionId).toBeNull(); - expect(after?.laneRole).toBeNull(); - }); - - it("setMissionOwnership throws for empty laneId", () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-empty-id-")); - - // Create a minimal service — we only need the sync method to throw - const setup = async () => { - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - const now = "2026-03-11T12:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - ["proj-empty-id", repoRoot, "demo", "main", now, now], - ); - return createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-empty-id", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - }; - - return setup().then((service) => { - expect(() => - service.setMissionOwnership({ laneId: "", missionId: "m-1", laneRole: "worker" }) - ).toThrow("laneId is required"); - }); - }); - - it("setMissionOwnership throws for non-existent lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-no-lane-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - const now = "2026-03-11T12:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - ["proj-no-lane", repoRoot, "demo", "main", now, now], - ); - - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-no-lane", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - - expect(() => - service.setMissionOwnership({ laneId: "nonexistent", missionId: "m-1", laneRole: "worker" }) - ).toThrow("Lane not found: nonexistent"); - }); - - it("toLaneSummary maps mission_id and lane_role from DB row", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-summary-map-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - await seedProjectAndStack(db, { projectId: "proj-summary-map", repoRoot }); - - // Insert mission row so FK constraint is satisfied, then set mission fields - db.run( - `insert into missions(id, project_id, title, prompt, status, priority, execution_mode, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["mission-map-test", "proj-summary-map", "Test Mission", "test", "active", "medium", "background", new Date().toISOString(), new Date().toISOString()] - ); - db.run("update lanes set mission_id = ?, lane_role = ? where id = ?", ["mission-map-test", "integration", "lane-parent"]); - - vi.mocked(runGit).mockImplementation(async (args: string[]) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { - return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; - } - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-summary-map", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - }); - - const lanes = await service.list({ includeStatus: false }); - const lane = lanes.find((l) => l.id === "lane-parent"); - expect(lane?.missionId).toBe("mission-map-test"); - expect(lane?.laneRole).toBe("integration"); - }); }); describe("laneService updateAppearance color uniqueness", () => { @@ -3384,36 +3052,6 @@ describe("laneService delete teardown + cancellation + streaming", () => { ["candidate-child", "review-run-child", "reviewer-run-child", "diff-risk", "Candidate", "medium", "Body", "anchored", now], ); - db.run( - ` - insert into missions( - id, project_id, lane_id, mission_lane_id, result_lane_id, - title, prompt, status, priority, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ["mission-child", projectId, "lane-child", "lane-child", "lane-child", "Mission", "Prompt", "active", "normal", now, now], - ); - db.run( - "insert into mission_steps(id, mission_id, project_id, step_index, title, lane_id, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ["mission-step-child", "mission-child", projectId, 0, "Step", "lane-child", "pending", now, now], - ); - db.run( - "insert into mission_artifacts(id, mission_id, project_id, artifact_type, title, lane_id, created_at, updated_at, created_by) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ["mission-artifact-child", "mission-child", projectId, "file", "Artifact", "lane-child", now, now, "agent"], - ); - db.run( - "insert into mission_interventions(id, mission_id, project_id, intervention_type, status, title, body, lane_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ["mission-intervention-child", "mission-child", projectId, "question", "open", "Question", "Body", "lane-child", now, now], - ); - db.run( - "insert into orchestrator_chat_threads(id, project_id, mission_id, thread_type, title, lane_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", - ["thread-child", projectId, "mission-child", "worker", "Thread", "lane-child", now, now], - ); - db.run( - "insert into orchestrator_chat_messages(id, project_id, mission_id, thread_id, role, content, timestamp, lane_id, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ["message-child", projectId, "mission-child", "thread-child", "assistant", "ok", now, "lane-child", now], - ); - await service.delete({ laneId: "lane-child", deleteBranch: false }); const count = (table: string, where: string, params: any[] = []) => @@ -3449,17 +3087,6 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(count("rebase_deferred", "lane_id = ?", ["lane-child"])).toBe(0); expect(count("rebase_dismissed", "lane_id = ?", ["lane-child"])).toBe(0); expect(count("lane_worktree_locks", "lane_id = ?", ["lane-child"])).toBe(0); - expect( - db.get<{ lane_id: string | null; mission_lane_id: string | null; result_lane_id: string | null }>( - "select lane_id, mission_lane_id, result_lane_id from missions where id = ?", - ["mission-child"], - ), - ).toMatchObject({ lane_id: null, mission_lane_id: null, result_lane_id: null }); - expect(db.get<{ lane_id: string | null }>("select lane_id from mission_steps where id = ?", ["mission-step-child"])?.lane_id).toBeNull(); - expect(db.get<{ lane_id: string | null }>("select lane_id from mission_artifacts where id = ?", ["mission-artifact-child"])?.lane_id).toBeNull(); - expect(db.get<{ lane_id: string | null }>("select lane_id from mission_interventions where id = ?", ["mission-intervention-child"])?.lane_id).toBeNull(); - expect(db.get<{ lane_id: string | null }>("select lane_id from orchestrator_chat_threads where id = ?", ["thread-child"])?.lane_id).toBeNull(); - expect(db.get<{ lane_id: string | null }>("select lane_id from orchestrator_chat_messages where id = ?", ["message-child"])?.lane_id).toBeNull(); }); it("does not cancel a lane delete after it starts", async () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 00857354d..f1e9d9ec6 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -40,7 +40,6 @@ import type { LaneLinearIssueLinkRole, LaneLinearIssueLinkSource, LaneRuntimePlacement, - MissionLaneRole, LaneStateSnapshotSummary, LaneStatus, LaneSummary, @@ -81,8 +80,6 @@ type LaneRow = { icon: string | null; tags_json: string | null; folder: string | null; - mission_id: string | null; - lane_role: MissionLaneRole | null; runtime_placement: LaneRuntimePlacement | null; created_at: string; archived_at: string | null; @@ -92,7 +89,6 @@ type LaneRow = { type LaneStateSnapshotRow = { lane_id: string; agent_summary_json: string | null; - mission_summary_json: string | null; updated_at: string | null; }; @@ -508,8 +504,6 @@ function toLaneSummary(args: { icon: parseLaneIcon(row.icon), tags: parseLaneTags(row.tags_json), folder: row.folder, - missionId: row.mission_id, - laneRole: row.lane_role, runtimePlacement: normalizeRuntimePlacement(row.runtime_placement), createdAt: row.created_at, archivedAt: row.archived_at, @@ -1018,15 +1012,14 @@ export function createLaneService({ laneId: string; status: LaneStatus; agentSummary?: Record | null; - missionSummary?: Record | null; updatedAt?: string; }): void => { db.run( ` insert into lane_state_snapshots( lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, - agent_summary_json, mission_summary_json, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + agent_summary_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) on conflict(lane_id) do update set dirty = excluded.dirty, ahead = excluded.ahead, @@ -1034,7 +1027,6 @@ export function createLaneService({ remote_behind = excluded.remote_behind, rebase_in_progress = excluded.rebase_in_progress, agent_summary_json = excluded.agent_summary_json, - mission_summary_json = excluded.mission_summary_json, updated_at = excluded.updated_at `, [ @@ -1045,7 +1037,6 @@ export function createLaneService({ args.status.remoteBehind, args.status.rebaseInProgress ? 1 : 0, args.agentSummary == null ? null : JSON.stringify(args.agentSummary), - args.missionSummary == null ? null : JSON.stringify(args.missionSummary), args.updatedAt ?? new Date().toISOString(), ], ); @@ -2108,8 +2099,6 @@ export function createLaneService({ startPoint: string; parentLaneId: string | null; folder?: string; - missionId?: string | null; - laneRole?: MissionLaneRole | null; branchName?: string | null; linearIssue?: LaneLinearIssue | null; runtimePlacement?: LaneRuntimePlacement | null; @@ -2139,9 +2128,9 @@ export function createLaneService({ ` insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, mission_id, lane_role, runtime_placement, status, created_at, archived_at + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, runtime_placement, status, created_at, archived_at ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, ?, ?, ?, ?, 'active', ?, null) + values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, ?, ?, 'active', ?, null) `, [ laneId, @@ -2153,8 +2142,6 @@ export function createLaneService({ worktreePath, args.parentLaneId, args.folder ?? null, - args.missionId ?? null, - args.laneRole ?? null, runtimePlacement, now ] @@ -2316,25 +2303,6 @@ export function createLaneService({ db.run("update lane_branch_profiles set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("update pr_convergence_state set active_lane_id = null where active_lane_id = ?", [laneId]); db.run("update linear_workflow_runs set execution_lane_id = null where execution_lane_id = ? and project_id = ?", [laneId, projectId]); - db.run( - ` - update missions - set lane_id = case when lane_id = ? then null else lane_id end, - mission_lane_id = case when mission_lane_id = ? then null else mission_lane_id end, - result_lane_id = case when result_lane_id = ? then null else result_lane_id end - where project_id = ? - and (lane_id = ? or mission_lane_id = ? or result_lane_id = ?) - `, - [laneId, laneId, laneId, projectId, laneId, laneId, laneId], - ); - db.run("update mission_steps set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update mission_artifacts set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update mission_interventions set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update orchestrator_steps set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update orchestrator_chat_threads set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update orchestrator_chat_messages set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update orchestrator_worker_digests set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("update orchestrator_lane_decisions set lane_id = null where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("update integration_proposals set integration_lane_id = null where integration_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("update integration_proposals set preferred_integration_lane_id = null where preferred_integration_lane_id = ? and project_id = ?", [laneId, projectId]); @@ -2398,7 +2366,7 @@ export function createLaneService({ getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null { const row = db.get( ` - select s.lane_id, s.agent_summary_json, s.mission_summary_json, s.updated_at + select s.lane_id, s.agent_summary_json, s.updated_at from lane_state_snapshots s join lanes l on l.id = s.lane_id where s.lane_id = ? @@ -2411,7 +2379,6 @@ export function createLaneService({ return { laneId: row.lane_id, agentSummary: parseSummaryRecord(row.agent_summary_json), - missionSummary: parseSummaryRecord(row.mission_summary_json), updatedAt: row.updated_at ?? null, }; }, @@ -2419,7 +2386,7 @@ export function createLaneService({ listStateSnapshots(): LaneStateSnapshotSummary[] { return db.all( ` - select s.lane_id, s.agent_summary_json, s.mission_summary_json, s.updated_at + select s.lane_id, s.agent_summary_json, s.updated_at from lane_state_snapshots s join lanes l on l.id = s.lane_id where l.project_id = ? @@ -2428,7 +2395,6 @@ export function createLaneService({ ).map((row) => ({ laneId: row.lane_id, agentSummary: parseSummaryRecord(row.agent_summary_json), - missionSummary: parseSummaryRecord(row.mission_summary_json), updatedAt: row.updated_at ?? null, })); }, @@ -2628,8 +2594,6 @@ export function createLaneService({ startPoint, parentLaneId: parent.id, folder: args.folder, - missionId: args.missionId ?? null, - laneRole: args.laneRole ?? null, branchName: args.branchName, linearIssue: args.linearIssue ?? null, runtimePlacement: args.runtimePlacement, @@ -2650,8 +2614,6 @@ export function createLaneService({ startPoint, parentLaneId: null, folder: args.folder, - missionId: args.missionId ?? null, - laneRole: args.laneRole ?? null, branchName: args.branchName, linearIssue: args.linearIssue ?? null, runtimePlacement: args.runtimePlacement, @@ -2667,36 +2629,12 @@ export function createLaneService({ startPoint: parentHeadSha, parentLaneId: parent.id, folder: args.folder, - missionId: args.missionId ?? null, - laneRole: args.laneRole ?? null, branchName: args.branchName, linearIssue: args.linearIssue ?? null, runtimePlacement: args.runtimePlacement, }); }, - setMissionOwnership(args: { - laneId: string; - missionId?: string | null; - laneRole?: MissionLaneRole | null; - }): void { - const laneId = args.laneId.trim(); - if (!laneId.length) throw new Error("laneId is required."); - const existing = getLaneRow(laneId); - if (!existing) throw new Error(`Lane not found: ${laneId}`); - db.run( - ` - update lanes - set mission_id = ?, - lane_role = ? - where id = ? - and project_id = ? - `, - [args.missionId?.trim() || null, args.laneRole ?? null, laneId, projectId] - ); - invalidateLaneListCache(); - }, - async createFromUnstaged(args: CreateLaneFromUnstagedArgs): Promise { const sourceLaneId = args.sourceLaneId.trim(); const name = args.name.trim(); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 1b91669ec..f478d2c2c 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -879,7 +879,7 @@ function clampLimit(value: unknown): number { } function isRemoteRuntimeEventCategory(value: unknown): value is RemoteRuntimeEventCategory { - return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || value === "mission" || value === "pty"; + return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || value === "pty"; } function normalizeBufferedEvent(value: unknown): RemoteRuntimeBufferedEvent | null { diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts deleted file mode 100644 index 7b0639cd6..000000000 --- a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts +++ /dev/null @@ -1,956 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MissionPreflightBudgetEstimate, PhaseProfile } from "../../../shared/types"; -import { createBuiltInPhaseCards } from "./phaseEngine"; -import { createMissionPreflightService } from "./missionPreflightService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function createProfiles(): PhaseProfile[] { - const phases = createBuiltInPhaseCards("2026-02-27T00:00:00.000Z"); - return [ - { - id: "profile:default", - name: "Default", - description: "Default mission profile", - phases, - isBuiltIn: true, - isDefault: true, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - } - ]; -} - -function createBudgetEstimate(mode: "subscription" | "api-key"): MissionPreflightBudgetEstimate { - const phases = createBuiltInPhaseCards("2026-02-27T00:00:00.000Z"); - return { - mode, - estimatedTokens: 12_000, - estimatedCostUsd: 2.4, - estimatedTimeMs: 30 * 60_000, - perPhase: phases.map((phase) => ({ - phaseKey: phase.phaseKey, - phaseName: phase.name, - estimatedTokens: 2_400, - estimatedCostUsd: 0.48, - estimatedTimeMs: 6 * 60_000, - configuredMaxTokens: phase.budget.maxTokens ?? null, - configuredMaxTimeMs: phase.budget.maxTimeMs ?? null - })) - }; -} - -describe("missionPreflightService", () => { - it("passes launch preflight when runtime prerequisites are satisfied", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - { id: "lane-4", archivedAt: null }, - ] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11 - }) - } as any - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Implement mission orchestration improvements.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - }, - executionPolicy: { - teamRuntime: { - enabled: true, - teammateCount: 2 - } - } as any - } - }); - - expect(result.canLaunch).toBe(true); - expect(result.hardFailures).toBe(0); - expect(result.checklist.find((item) => item.id === "models")?.severity).toBe("pass"); - expect(result.checklist.find((item) => item.id === "permissions")?.severity).toBe("pass"); - expect(result.checklist.find((item) => item.id === "worktrees")?.severity).toBe("pass"); - expect(result.checklist.find((item) => item.id === "budget")?.severity).toBe("warning"); - expect(result.approvalSummary?.missionGoal).toBe("Implement mission orchestration improvements."); - expect(result.approvalSummary?.recommendedExecution.teamRuntimeEnabled).toBe(true); - expect(result.approvalSummary?.phaseLabels.length).toBeGreaterThan(0); - }); - - it("blocks launch when budget estimate exceeds API-key envelope", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [{ id: "lane-1", archivedAt: null }, { id: "lane-2", archivedAt: null }] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("api-key"), - hardLimitExceeded: true, - windowUsageCostUsd: 1.5, - remainingWindowCostUsd: 0.2, - budgetLimitCostUsd: 1.7 - }) - } as any - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Implement mission orchestration improvements.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - } - } - }); - - expect(result.canLaunch).toBe(false); - expect(result.hardFailures).toBeGreaterThan(0); - expect(result.checklist.find((item) => item.id === "budget")?.severity).toBe("fail"); - }); - - it("blocks launch when required computer-use proof has no backend coverage", async () => { - const profiles = createProfiles(); - const proofPhases = profiles[0]!.phases.map((phase, index) => - index === 0 - ? { - ...phase, - validationGate: { - ...phase.validationGate, - required: true, - evidenceRequirements: ["screenshot" as const], - capabilityFallback: "block" as const, - }, - } - : phase, - ); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - ] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11 - }) - } as any, - computerUseArtifactBrokerService: { - getBackendStatus: () => ({ - backends: [], - localFallback: { - available: false, - detail: "No local fallback in test.", - supportedKinds: [], - }, - }), - } as any, - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Capture required proof.", - phaseProfileId: profiles[0]!.id, - phaseOverride: proofPhases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - }, - } - }); - - expect(result.canLaunch).toBe(false); - expect(result.computerUse?.blocked).toBe(true); - expect(result.computerUse?.missingKinds).toEqual(["screenshot"]); - expect(result.checklist.find((item) => item.id === "computer_use")?.severity).toBe("fail"); - }); - - it("allows runtime-discovered models when external proof backend covers browser evidence", async () => { - const profiles = createProfiles(); - const proofPhases = profiles[0]!.phases.map((phase, index) => ({ - ...phase, - model: { - ...phase.model, - provider: "opencode", - modelId: index === 0 ? "runtime/non-registry-model" : phase.model.modelId, - }, - validationGate: index === 0 - ? { - ...phase.validationGate, - required: true, - evidenceRequirements: ["screenshot" as const], - capabilityFallback: "block" as const, - } - : phase.validationGate, - })); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - ] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "runtime/non-registry-model", shortId: "runtime-model", family: "opencode", displayName: "Runtime model" }, - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11 - }) - } as any, - computerUseArtifactBrokerService: { - getBackendStatus: () => ({ - backends: [ - { - name: "agent-browser", - available: true, - state: "installed", - detail: "agent-browser is available.", - supportedKinds: ["screenshot"], - }, - ], - localFallback: { - available: false, - detail: "No local fallback in test.", - supportedKinds: [], - }, - }), - } as any, - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Capture required proof.", - phaseProfileId: profiles[0]!.id, - phaseOverride: proofPhases, - modelConfig: { - orchestratorModel: { - provider: "opencode", - modelId: "runtime/non-registry-model" - } - }, - } - }); - - expect(result.canLaunch).toBe(true); - expect(result.checklist.find((item) => item.id === "computer_use")?.severity).toBe("pass"); - expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("pass"); - }); - - it("reports local computer-use platform blockers when backend status is wired", async () => { - const profiles = createProfiles(); - const proofPhases = profiles[0]!.phases.map((phase, index) => ({ - ...phase, - validationGate: index === 0 - ? { - ...phase.validationGate, - required: true, - evidenceRequirements: ["video_recording" as const], - capabilityFallback: "block" as const, - } - : phase.validationGate, - })); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [{ id: "lane-1", archivedAt: null }] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0, - remainingWindowCostUsd: 1, - budgetLimitCostUsd: 1 - }) - } as any, - computerUseArtifactBrokerService: { - getBackendStatus: () => ({ - backends: [], - localFallback: { - available: false, - state: "blocked_by_capability", - detail: "ADE local computer-use tools are fallback-only and currently blocked_by_capability.", - supportedKinds: [], - }, - }), - } as any, - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Capture required proof.", - phaseProfileId: profiles[0]!.id, - phaseOverride: proofPhases, - } - }); - - expect(result.canLaunch).toBe(false); - expect(result.checklist.find((item) => item.id === "capabilities")?.details.join("\n")).toContain("blocked by platform support"); - }); - - it("shows warning (not fail) for non-full-auto permissions and still allows launch", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - { id: "lane-4", archivedAt: null }, - ] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "edit" }, - inProcess: { mode: "plan" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11 - }) - } as any - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Implement feature.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - }, - } - }); - - const permItem = result.checklist.find((item) => item.id === "permissions"); - expect(permItem?.severity).toBe("warning"); - expect(result.canLaunch).toBe(true); - expect(result.hardFailures).toBe(0); - // Warning details should mention the specific mode - expect(permItem?.details?.some((d) => d.includes("edit"))).toBe(true); - }); - - it("uses per-provider permissions from providers field over old cli/inProcess", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - { id: "lane-4", archivedAt: null }, - ] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "edit" }, - inProcess: { mode: "plan" }, - providers: { claude: "full-auto", codex: "full-auto", opencode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11 - }) - } as any - }); - - const projectResult = await service.runPreflight({ - launch: { - prompt: "Implement feature.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - }, - } - }); - - expect(projectResult.checklist.find((item) => item.id === "permissions")?.severity).toBe("pass"); - - // Mission-level providers field overrides all old cli/inProcess modes - const result = await service.runPreflight({ - launch: { - prompt: "Implement feature.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - }, - permissionConfig: { - providers: { claude: "full-auto", codex: "full-auto", opencode: "full-auto" }, - }, - } - }); - - const permItem = result.checklist.find((item) => item.id === "permissions"); - // providers overrides project-level cli.mode=edit for all families - expect(permItem?.severity).toBe("pass"); - expect(result.canLaunch).toBe(true); - }); - - it("fails launch preflight when the selected lane is claimed by another mission", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles, - isLaneClaimed: () => ({ - claimed: true, - byMissionId: "mission-owner", - reason: "Lane is already owned by another mission.", - }), - } as any, - laneService: { - list: async () => [ - { id: "lane-1", name: "Primary", archivedAt: null }, - { id: "lane-2", name: "Throwaway", archivedAt: null }, - { id: "lane-3", name: "Worker", archivedAt: null }, - ] - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ] - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - } - } - } - }) - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11 - }) - } as any - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Implement mission orchestration improvements.", - laneId: "lane-1", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - } - } - } - }); - - expect(result.canLaunch).toBe(false); - expect(result.checklist.find((item) => item.id === "lane_claim")).toMatchObject({ - severity: "fail", - summary: "Lane is already owned by another mission.", - }); - }); - - const runCodexLabelPreflight = async (codexMode: "default" | "plan" | "full-auto" | "config-toml") => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles, - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - { id: "lane-4", archivedAt: null }, - ], - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, - ], - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }), - } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.6, - remainingWindowCostUsd: 10.4, - budgetLimitCostUsd: 11, - }), - } as any, - }); - - return service.runPreflight({ - launch: { - prompt: "Implement feature.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6", - }, - }, - permissionConfig: { - providers: { claude: "full-auto", codex: codexMode, opencode: "full-auto" }, - }, - }, - }); - }; - - it("Codex default mode shows 'Default permissions' label and warning", async () => { - const result = await runCodexLabelPreflight("default"); - const permItem = result.checklist.find((item) => item.id === "permissions"); - expect(permItem).toBeDefined(); - expect(permItem?.severity).toBe("warning"); - expect(permItem?.details?.some((d) => d.includes("Codex workers: Default permissions"))).toBe(true); - expect(permItem?.details?.some((d) => d.includes("Default permissions may still pause for approvals"))).toBe(true); - }); - - it("Codex full-auto shows 'Full access' label without warning", async () => { - const result = await runCodexLabelPreflight("full-auto"); - const permItem = result.checklist.find((item) => item.id === "permissions"); - expect(permItem).toBeDefined(); - expect(permItem?.severity).toBe("pass"); - expect(permItem?.details?.some((d) => d.includes("Codex workers: Full access"))).toBe(true); - expect(permItem?.details?.some((d) => d.includes("may still pause for approvals"))).toBe(false); - }); - - it("Codex config-toml shows 'Custom (config.toml)' label without warning", async () => { - const result = await runCodexLabelPreflight("config-toml"); - const permItem = result.checklist.find((item) => item.id === "permissions"); - expect(permItem).toBeDefined(); - expect(permItem?.severity).toBe("pass"); - expect(permItem?.details?.some((d) => d.includes("Codex workers: Custom (config.toml)"))).toBe(true); - expect(permItem?.details?.some((d) => d.includes("may still pause for approvals"))).toBe(false); - }); - - it("checks custom phase instructions locally without blocking on an AI task", async () => { - const profiles = createProfiles(); - const customPhase = { - ...profiles[0]!.phases[4]!, - id: "custom:score-audit", - phaseKey: "score_audit", - name: "Score audit", - description: "Inspect throwaway output before closeout.", - instructions: "Inspect the changed files, verify the targeted test output, and write an artifact with proof before closeout.", - isBuiltIn: false, - isCustom: true, - orderingConstraints: {}, - validationGate: { tier: "none" as const, required: false }, - position: 5, - }; - const phaseOverride = [ - ...profiles[0]!.phases.slice(0, 5), - customPhase, - { ...profiles[0]!.phases[5]!, position: 6 }, - ].map((phase, index) => ({ ...phase, position: index })); - const executeTask = vi.fn(async () => { - throw new Error("semantic preflight should not call AI"); - }); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles, - } as any, - laneService: { - list: async () => [ - { id: "lane-1", archivedAt: null }, - { id: "lane-2", archivedAt: null }, - { id: "lane-3", archivedAt: null }, - { id: "lane-4", archivedAt: null }, - ], - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - { id: "openai/gpt-5.5", shortId: "gpt-5.5", family: "openai", displayName: "GPT-5.5" }, - ], - }), - executeTask, - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - providers: { - openai: { mode: "full-auto" }, - anthropic: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - }, - }, - }, - }, - }), - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.1, - remainingWindowCostUsd: 10.9, - budgetLimitCostUsd: 11, - }), - } as any, - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Run a custom phase smoke mission.", - phaseProfileId: profiles[0]!.id, - phaseOverride, - modelConfig: { - orchestratorModel: { - provider: "openai", - modelId: "openai/gpt-5.5", - }, - }, - } as any, - }); - - expect(executeTask).not.toHaveBeenCalled(); - expect(result.canLaunch).toBe(true); - const semanticItem = result.checklist.find((item) => item.id === "phase_semantic"); - expect(semanticItem?.severity).toBe("pass"); - expect(semanticItem?.details.join("\n")).toContain("local checks"); - }); - - it("summarizes result-lane closeout for new missions without requiring PR automation", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles, - } as any, - laneService: { - list: async () => [{ id: "lane-1", archivedAt: null }, { id: "lane-2", archivedAt: null }], - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "google/gemini-2.5-flash", shortId: "gemini-2.5-flash", family: "google", displayName: "Gemini 2.5 Flash" }, - ], - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }), - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - }, - }, - }, - }), - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.1, - remainingWindowCostUsd: 10.9, - budgetLimitCostUsd: 11, - }), - } as any, - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Land the consolidated implementation in one lane.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases.map((phase) => ({ - ...phase, - model: { - ...phase.model, - provider: "openrouter", - modelId: "google/gemini-2.5-flash", - }, - })), - modelConfig: { - orchestratorModel: { - provider: "openai", - modelId: "google/gemini-2.5-flash", - }, - }, - } as any, - }); - - expect(result.canLaunch).toBe(true); - expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("pass"); - expect( - result.approvalSummary?.conflictAssumptions.some((detail) => - detail.includes("result lane") && detail.includes("internal integration lane") && detail.includes("will not auto-open an external GitHub PR"), - ), - ).toBe(true); - }); - -}); diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.ts b/apps/desktop/src/main/services/missions/missionPreflightService.ts deleted file mode 100644 index 796a2ac6a..000000000 --- a/apps/desktop/src/main/services/missions/missionPreflightService.ts +++ /dev/null @@ -1,793 +0,0 @@ -import type { Logger } from "../logging/logger"; -import type { createMissionService } from "./missionService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { - ComputerUseArtifactKind, - MissionPreflightChecklistItem, - MissionPreflightRequest, - MissionPreflightResult, - PhaseCard, - PhaseProfile, -} from "../../../shared/types"; -import { createBuiltInPhaseCards, ensurePlanningPhase, validatePhaseSequence } from "./phaseEngine"; -import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; -import type { MissionBudgetService } from "../orchestrator/missionBudgetService"; -import { mergeMissionPermissionConfig, normalizeMissionPermissions } from "../orchestrator/permissionMapping"; -import type { MissionPermissionConfig } from "../../../shared/types/missions"; -import { nowIso, toOptionalString } from "../shared/utils"; -import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import { - collectRequiredComputerUseKindsFromPhases, - getComputerUseArtifactKinds, -} from "../computerUse/controlPlane"; -import { getCapabilityForRequirement } from "../computerUse/localComputerUse"; - - -function normalizePhaseCards(phases: PhaseCard[]): PhaseCard[] { - return [...phases] - .sort((a, b) => a.position - b.position) - .map((phase, index) => { - const requiresApproval = phase.requiresApproval === true; - return { ...phase, requiresApproval, position: index }; - }); -} - -function summarizeDuration(ms: number | null): string { - if (ms == null || !Number.isFinite(ms) || ms <= 0) return "n/a"; - const mins = Math.max(1, Math.round(ms / 60_000)); - if (mins >= 60) { - const hours = Math.floor(mins / 60); - const rem = mins % 60; - return rem > 0 ? `${hours}h ${rem}m` : `${hours}h`; - } - return `${mins}m`; -} - -function summarizeUsd(value: number | null): string { - if (value == null || !Number.isFinite(value)) return "n/a"; - return `$${value.toFixed(2)}`; -} - -function toChecklistItem(args: { - id: MissionPreflightChecklistItem["id"]; - severity: MissionPreflightChecklistItem["severity"]; - title: string; - summary: string; - details: string[]; - fixHint?: string; -}): MissionPreflightChecklistItem { - return { - id: args.id, - severity: args.severity, - title: args.title, - summary: args.summary, - details: args.details, - ...(args.fixHint ? { fixHint: args.fixHint } : {}), - }; -} - -function analyzeCustomPhaseInstructions(phases: PhaseCard[]): string[] { - const warnings: string[] = []; - const actionPattern = /\b(add|apply|build|capture|check|confirm|create|inspect|implement|produce|record|report|review|run|summarize|test|update|verify|write)\b/i; - const outputPattern = /\b(artifact|criteria|done|evidence|output|pass|proof|report|result|summary|verify)\b/i; - - for (const phase of phases) { - const instructions = phase.instructions?.trim() ?? ""; - const label = phase.name?.trim() || phase.phaseKey || "Custom phase"; - if (instructions.length < 20) { - warnings.push(`${label}: instructions look too short for autonomous execution.`); - continue; - } - if (!actionPattern.test(instructions)) { - warnings.push(`${label}: instructions should include a concrete action for the worker.`); - } - if (!outputPattern.test(instructions)) { - warnings.push(`${label}: instructions should name the expected output or done criteria.`); - } - } - - return warnings; -} - -function resolveSelectedPhases(args: { - launch: MissionPreflightRequest["launch"]; - profiles: PhaseProfile[]; -}): { profile: PhaseProfile | null; phases: PhaseCard[] } { - const requestedProfileId = toOptionalString(args.launch.phaseProfileId); - const selectedProfile = requestedProfileId - ? args.profiles.find((profile) => profile.id === requestedProfileId) ?? null - : 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( - ensurePlanningPhase( - hasOverride - ? args.launch.phaseOverride ?? [] - : selectedProfile?.phases?.length - ? selectedProfile.phases - : createBuiltInPhaseCards() - ) - ); - return { profile: selectedProfile, phases }; -} - -export function createMissionPreflightService(args: { - logger: Logger; - projectRoot: string; - missionService: ReturnType; - laneService: ReturnType; - aiIntegrationService: ReturnType; - projectConfigService: ReturnType; - missionBudgetService: MissionBudgetService; - computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; -}) { - const { - missionService, - laneService, - aiIntegrationService, - projectConfigService, - missionBudgetService, - computerUseArtifactBrokerService, - } = args; - - const runPreflight = async (request: MissionPreflightRequest): Promise => { - const launch = request.launch; - const profiles = missionService.listPhaseProfiles({}); - const selected = resolveSelectedPhases({ launch, profiles }); - - const checklist: MissionPreflightChecklistItem[] = []; - const backendStatus = computerUseArtifactBrokerService?.getBackendStatus() ?? null; - const supportedComputerUseKinds = new Set(getComputerUseArtifactKinds()); - const requiredComputerUseKinds = collectRequiredComputerUseKindsFromPhases(selected.phases); - const hasExternalComputerUseCoverage = (kind: ComputerUseArtifactKind): boolean => - backendStatus?.backends.some((backend) => - backend.available && backend.supportedKinds.includes(kind) - ) ?? false; - const hasLocalComputerUseCoverage = (kind: ComputerUseArtifactKind): boolean => - backendStatus - ? (backendStatus.localFallback?.supportedKinds.includes(kind) ?? false) - : (getCapabilityForRequirement(kind)?.available ?? false); - const availableExternalBackends = (backendStatus?.backends ?? []) - .filter((backend) => - backend.available - && ( - requiredComputerUseKinds.length === 0 - || requiredComputerUseKinds.some((kind) => backend.supportedKinds.includes(kind)) - ) - ) - .map((backend) => backend.name); - const fallbackCoverageKinds = requiredComputerUseKinds.filter((kind) => - hasLocalComputerUseCoverage(kind) - ); - const missingComputerUseKinds = requiredComputerUseKinds.filter((kind) => { - return !hasExternalComputerUseCoverage(kind) && !hasLocalComputerUseCoverage(kind); - }); - const blockingMissingComputerUseKinds = Array.from(new Set( - selected.phases.flatMap((phase) => { - if (!phase.validationGate.required) return []; - if ((phase.validationGate.capabilityFallback ?? "block") !== "block") return []; - return (phase.validationGate.evidenceRequirements ?? []).filter((requirement): requirement is ComputerUseArtifactKind => - supportedComputerUseKinds.has(requirement as ComputerUseArtifactKind) - && missingComputerUseKinds.includes(requirement as ComputerUseArtifactKind) - ); - }), - )); - const warningMissingComputerUseKinds = missingComputerUseKinds.filter((kind) => - !blockingMissingComputerUseKinds.includes(kind) - ); - const fallbackOnlyKinds = requiredComputerUseKinds.filter((kind) => { - return !hasExternalComputerUseCoverage(kind) && hasLocalComputerUseCoverage(kind); - }); - const computerUseBlocked = requiredComputerUseKinds.length > 0 - && blockingMissingComputerUseKinds.length > 0; - - const structuralIssues: string[] = []; - for (const [index, phase] of selected.phases.entries()) { - const prefix = `Phase ${index + 1} (${phase.name || phase.phaseKey || "unnamed"})`; - if (!toOptionalString(phase.phaseKey)) structuralIssues.push(`${prefix}: missing phase key.`); - if (!toOptionalString(phase.name)) structuralIssues.push(`${prefix}: missing phase name.`); - if (!toOptionalString(phase.description)) structuralIssues.push(`${prefix}: missing description.`); - if (!toOptionalString(phase.instructions)) structuralIssues.push(`${prefix}: missing instructions.`); - if (!toOptionalString(phase.model.modelId)) structuralIssues.push(`${prefix}: missing model ID.`); - } - checklist.push( - structuralIssues.length === 0 - ? toChecklistItem({ - id: "phase_structural", - severity: "pass", - title: "Phase configuration — structural", - summary: `All ${selected.phases.length} phase cards have required fields.`, - details: [`Profile: ${selected.profile?.name ?? "Built-in default"}`], - }) - : toChecklistItem({ - id: "phase_structural", - severity: "fail", - title: "Phase configuration — structural", - summary: "One or more phase cards are missing required fields.", - details: structuralIssues, - fixHint: "Fill in each phase card's key, name, description, instructions, and model configuration.", - }), - ); - - const orderingErrors = validatePhaseSequence(selected.phases); - checklist.push( - orderingErrors.length === 0 - ? toChecklistItem({ - id: "phase_ordering", - severity: "pass", - title: "Phase configuration — ordering", - summary: "Ordering constraints are satisfiable.", - details: ["No cycles or ordering violations detected."], - }) - : toChecklistItem({ - id: "phase_ordering", - severity: "fail", - title: "Phase configuration — ordering", - summary: "Ordering constraints are invalid.", - details: orderingErrors, - fixHint: "Reorder phases or update mustFollow/mustPrecede constraints to remove conflicts.", - }), - ); - - let modelAvailabilityDetails: string[] = []; - let modelFailures: string[] = []; - let orchestratorModelId: string | null = null; - let availabilityModels: Array<{ id: string; shortId: string; family: string; displayName: string }> = []; - try { - const availability = await aiIntegrationService.getAvailabilityAsync(); - availabilityModels = Array.isArray((availability as { availableModels?: unknown[] }).availableModels) - ? ((availability as { availableModels?: unknown[] }).availableModels as Array<{ id: string; shortId: string; family: string; displayName: string }>) - : []; - - const requestedModels: Array<{ label: string; modelId: string }> = []; - for (const phase of selected.phases) { - if (toOptionalString(phase.model.modelId)) { - requestedModels.push({ - label: `${phase.name}`, - modelId: phase.model.modelId, - }); - } - } - orchestratorModelId = toOptionalString(launch.modelConfig?.orchestratorModel?.modelId); - if (orchestratorModelId) { - requestedModels.push({ - label: "Orchestrator", - modelId: orchestratorModelId, - }); - } else { - modelFailures.push("Orchestrator: missing modelConfig.orchestratorModel.modelId."); - } - - for (const requestModel of requestedModels) { - const resolved = getModelById(requestModel.modelId) ?? resolveModelAlias(requestModel.modelId); - if (!resolved) { - const requestedRaw = requestModel.modelId.trim().toLowerCase(); - const availableDirect = availabilityModels.some((candidate) => { - const candidateId = typeof candidate.id === "string" ? candidate.id.trim().toLowerCase() : ""; - const candidateShortId = typeof candidate.shortId === "string" ? candidate.shortId.trim().toLowerCase() : ""; - return candidateId === requestedRaw || candidateShortId === requestedRaw; - }); - if (availableDirect) { - modelAvailabilityDetails.push(`${requestModel.label}: ${requestModel.modelId} — available`); - continue; - } - modelFailures.push(`${requestModel.label}: unknown model "${requestModel.modelId}".`); - continue; - } - const available = availabilityModels.some((candidate) => - candidate.id === resolved.id - || candidate.shortId === resolved.shortId - || candidate.family === resolved.family, - ); - if (!available) { - modelFailures.push(`${requestModel.label}: ${resolved.displayName} is not detected/authenticated.`); - } else { - modelAvailabilityDetails.push(`${requestModel.label}: ${resolved.displayName} — available`); - } - } - } catch (error) { - modelFailures.push(`Model detection failed: ${error instanceof Error ? error.message : String(error)}`); - } - - checklist.push( - modelFailures.length === 0 - ? toChecklistItem({ - id: "models", - severity: "pass", - title: "Models detected & authenticated", - summary: "All selected phase and orchestrator models are available.", - details: modelAvailabilityDetails.length > 0 ? modelAvailabilityDetails : ["Model detection succeeded."], - }) - : toChecklistItem({ - id: "models", - severity: "fail", - title: "Models detected & authenticated", - summary: "One or more selected models are unavailable.", - details: modelFailures, - fixHint: "Authenticate the required provider CLIs/API keys or switch the phase/orchestrator model.", - }), - ); - - const activeLanes = await laneService.list({ includeArchived: false }).catch(() => []); - const selectedLaneId = toOptionalString(launch.laneId); - const selectedLane = selectedLaneId - ? activeLanes.find((lane) => lane.id === selectedLaneId) ?? null - : null; - - if (selectedLaneId) { - const laneClaim = missionService.isLaneClaimed(selectedLaneId); - if (!selectedLane) { - checklist.push( - toChecklistItem({ - id: "lane_claim", - severity: "fail", - title: "Selected lane availability", - summary: "The selected lane is not available.", - details: [`Lane not found or archived: ${selectedLaneId}`], - fixHint: "Pick an active lane or create a new throwaway lane before launching.", - }), - ); - } else if (laneClaim.claimed) { - checklist.push( - toChecklistItem({ - id: "lane_claim", - severity: "fail", - title: "Selected lane availability", - summary: laneClaim.reason ?? "The selected lane is already claimed by another mission.", - details: [ - `Lane: ${selectedLane.name ?? selectedLaneId}`, - ...(laneClaim.byMissionId ? [`Owning mission: ${laneClaim.byMissionId}`] : []), - ], - fixHint: "Pick a different lane, delete/archive the owning mission with cleanup, or create a new throwaway lane.", - }), - ); - } else { - checklist.push( - toChecklistItem({ - id: "lane_claim", - severity: "pass", - title: "Selected lane availability", - summary: "Selected lane is available for mission launch.", - details: [`Lane: ${selectedLane.name ?? selectedLaneId}`], - }), - ); - } - } - - checklist.push( - requiredComputerUseKinds.length === 0 - ? toChecklistItem({ - id: "computer_use", - severity: "pass", - title: "Computer use readiness", - summary: "The selected phases do not require computer-use proof artifacts.", - details: [ - `External backends detected: ${availableExternalBackends.length > 0 ? availableExternalBackends.join(", ") : "none"}`, - ], - }) - : toChecklistItem({ - id: "computer_use", - severity: computerUseBlocked ? "fail" : fallbackOnlyKinds.length > 0 || warningMissingComputerUseKinds.length > 0 ? "warning" : "pass", - title: "Computer use readiness", - summary: computerUseBlocked - ? "Required computer-use proof is not fully covered in the current environment." - : warningMissingComputerUseKinds.length > 0 - ? "Some required computer-use proof is warning-only and not covered in the current environment." - : fallbackOnlyKinds.length > 0 - ? "Required proof is covered, but some evidence depends on ADE-local fallback support." - : "Required proof is covered by an available external computer-use backend.", - details: [ - `Required proof kinds: ${requiredComputerUseKinds.join(", ")}`, - `External backends detected: ${availableExternalBackends.length > 0 ? availableExternalBackends.join(", ") : "none"}`, - ...(fallbackCoverageKinds.length > 0 ? [`Local fallback can cover: ${fallbackCoverageKinds.join(", ")}`] : []), - ...(blockingMissingComputerUseKinds.length > 0 ? [`Blocking missing coverage: ${blockingMissingComputerUseKinds.join(", ")}`] : []), - ...(warningMissingComputerUseKinds.length > 0 ? [`Warning-only missing coverage: ${warningMissingComputerUseKinds.join(", ")}`] : []), - ], - ...(computerUseBlocked - ? { - fixHint: "Install or enable an approved external backend such as Ghost OS or agent-browser, run on a platform with ADE-local proof support, or relax the mission proof contract before launch.", - } - : {}), - }), - ); - - const capabilityIssues: string[] = []; - const capabilityWarnings: string[] = []; - for (const phase of selected.phases) { - const evidenceRequirements = phase.validationGate.evidenceRequirements ?? []; - if (!phase.validationGate.required || evidenceRequirements.length === 0) continue; - const descriptor = getModelById(phase.model.modelId) ?? resolveModelAlias(phase.model.modelId); - const requiresBrowserEvidence = evidenceRequirements.some((requirement) => - requirement === "screenshot" - || requirement === "browser_verification" - || requirement === "browser_trace" - || requirement === "video_recording" - ); - const browserEvidenceRequirements = evidenceRequirements.filter((requirement) => - requirement === "screenshot" - || requirement === "browser_verification" - || requirement === "browser_trace" - || requirement === "video_recording" - ); - const browserEvidenceCoveredByBackend = browserEvidenceRequirements.length > 0 && browserEvidenceRequirements.every((requirement) => { - const requirementKind = requirement as ComputerUseArtifactKind; - return supportedComputerUseKinds.has(requirementKind) - && (hasExternalComputerUseCoverage(requirementKind) || hasLocalComputerUseCoverage(requirementKind)); - }); - if (requiresBrowserEvidence && descriptor) { - const likelyBrowserCapable = browserEvidenceCoveredByBackend - || (descriptor.capabilities.tools && descriptor.capabilities.vision); - if (!likelyBrowserCapable) { - const message = `${phase.name}: requires browser/screenshot evidence, but ${descriptor.displayName} does not advertise the tool/vision support needed without an available proof backend.`; - if ((phase.validationGate.capabilityFallback ?? "block") === "block") capabilityIssues.push(message); - else capabilityWarnings.push(message); - } - } - if (requiresBrowserEvidence && !descriptor && !browserEvidenceCoveredByBackend) { - const message = `${phase.name}: requires browser/screenshot evidence, but model ${phase.model.modelId} could not be resolved for capability checks.`; - if ((phase.validationGate.capabilityFallback ?? "block") === "block") capabilityIssues.push(message); - else capabilityWarnings.push(message); - } - for (const requirement of evidenceRequirements) { - const requirementKind = requirement as ComputerUseArtifactKind; - if (!supportedComputerUseKinds.has(requirementKind)) continue; - if (hasExternalComputerUseCoverage(requirementKind)) continue; - if (hasLocalComputerUseCoverage(requirementKind)) continue; - const localCapability = getCapabilityForRequirement(requirementKind); - const localDetail = backendStatus?.localFallback?.detail ?? localCapability?.detail; - const localFallbackBlocked = backendStatus?.localFallback?.state === "blocked_by_capability"; - const localStateReason = localCapability?.state === "blocked_by_capability" || localFallbackBlocked - ? "blocked by platform support" - : "missing required tooling"; - const message = `${phase.name}: ${requirement.replace(/_/g, " ")} is required, but no external backend is available and the local runtime is ${localStateReason}${localDetail ? ` (${localDetail})` : ""}.`; - if ((phase.validationGate.capabilityFallback ?? "block") === "block") capabilityIssues.push(message); - else capabilityWarnings.push(message); - } - } - - checklist.push( - capabilityIssues.length === 0 && capabilityWarnings.length === 0 - ? toChecklistItem({ - id: "capabilities", - severity: "pass", - title: "Capability contracts", - summary: "Configured evidence contracts have matching runtime capabilities.", - details: ["No blocking capability gaps detected in the selected phase configuration."], - }) - : toChecklistItem({ - id: "capabilities", - severity: capabilityIssues.length > 0 ? "fail" : "warning", - title: "Capability contracts", - summary: capabilityIssues.length > 0 - ? "One or more required capabilities are missing for the selected mission contract." - : "Some optional capability-backed evidence may require fallback or operator review.", - details: [...capabilityIssues, ...capabilityWarnings], - fixHint: "Switch to models that support the required evidence flow, install an approved computer-use backend, or relax the phase contract before launch.", - }), - ); - - const requestedDescriptors = new Map>(); - for (const phase of selected.phases) { - const descriptor = getModelById(phase.model.modelId) ?? resolveModelAlias(phase.model.modelId); - if (descriptor) requestedDescriptors.set(descriptor.id, descriptor); - } - const orchestratorDescriptor = toOptionalString(launch.modelConfig?.orchestratorModel?.modelId) - ? getModelById(launch.modelConfig!.orchestratorModel.modelId) - ?? resolveModelAlias(launch.modelConfig!.orchestratorModel.modelId) - : null; - if (orchestratorDescriptor) requestedDescriptors.set(orchestratorDescriptor.id, orchestratorDescriptor); - - // Determine which model families are in use - const familiesInUse = new Set<"anthropic" | "openai" | "api">(); - for (const descriptor of requestedDescriptors.values()) { - if (!descriptor) continue; - if (descriptor.isCliWrapped && descriptor.family === "anthropic") familiesInUse.add("anthropic"); - else if (descriptor.isCliWrapped && descriptor.family === "openai") familiesInUse.add("openai"); - else if (!descriptor.isCliWrapped) familiesInUse.add("api"); - } - - // Build per-provider permission config from project + mission overrides - const config = projectConfigService.get(); - const aiConfig = config.effective.ai; - const projectPermissions = aiConfig?.permissions; - const projectPermConfig: MissionPermissionConfig = {}; - if (projectPermissions?.cli) { - projectPermConfig.cli = { - ...(typeof projectPermissions.cli.mode === "string" ? { mode: projectPermissions.cli.mode as MissionPermissionConfig["cli"] extends { mode?: infer M } ? M : never } : {}), - }; - } - if (projectPermissions?.inProcess) { - const m = projectPermissions.inProcess.mode; - if (m === "plan" || m === "edit" || m === "full-auto") projectPermConfig.inProcess = { mode: m }; - } - if (projectPermissions?.providers) { - projectPermConfig.providers = projectPermissions.providers; - } - const permissionConfig = launch.permissionConfig - ? mergeMissionPermissionConfig(projectPermConfig, launch.permissionConfig) - : projectPermConfig; - let providers = normalizeMissionPermissions(permissionConfig); - - const permissionWarnings: string[] = []; - const permissionDetails: string[] = []; - - if (familiesInUse.has("anthropic")) { - const mode = providers.claude ?? "full-auto"; - permissionDetails.push(`Claude workers: ${mode}`); - if (mode !== "full-auto") { - permissionWarnings.push(`Claude workers: ${mode} mode — shell commands require approval.`); - } - } - if (familiesInUse.has("openai")) { - const mode = providers.codex ?? "default"; - const label = mode === "default" - ? "Default permissions" - : mode === "plan" - ? "Plan mode" - : mode === "full-auto" - ? "Full access" - : mode === "config-toml" - ? "Custom (config.toml)" - : mode; - permissionDetails.push(`Codex workers: ${label}`); - if (mode !== "full-auto" && mode !== "config-toml") { - permissionWarnings.push(`Codex workers: ${label} may still pause for approvals.`); - } - } - if (familiesInUse.has("api")) { - const mode = providers.opencode ?? "full-auto"; - permissionDetails.push(`API workers: ${mode}`); - if (mode !== "full-auto") { - permissionWarnings.push(`API workers: ${mode} mode — modifications require approval.`); - } - } - - checklist.push( - permissionWarnings.length === 0 - ? toChecklistItem({ - id: "permissions", - severity: "pass", - title: "Permissions", - summary: "All worker families configured for unattended full-auto execution.", - details: permissionDetails.length > 0 ? permissionDetails : ["No provider-specific permission requirements detected."], - }) - : toChecklistItem({ - id: "permissions", - severity: "warning", - title: "Permissions", - summary: "Some workers may pause for approval during execution.", - details: [...permissionDetails, "", ...permissionWarnings], - fixHint: "Non-full-access modes are valid but workers may pause for user approval. Use full access only when the run is externally sandboxed.", - }), - ); - - let worktreeItem: MissionPreflightChecklistItem; - try { - const lanes = await laneService.list({}); - const availableLanes = lanes.filter((lane) => lane.archivedAt == null); - const expectedWorkers = - launch.executionPolicy?.teamRuntime?.enabled - ? Math.max(1, (launch.executionPolicy.teamRuntime.teammateCount ?? 2) + 1) - : 3; - if (availableLanes.length === 0) { - worktreeItem = toChecklistItem({ - id: "worktrees", - severity: "fail", - title: "Git worktrees available", - summary: "No lanes/worktrees are available for worker assignment.", - details: ["Create at least one lane before launching an autonomous mission."], - fixHint: "Create or import a lane from the Lanes tab and rerun pre-flight.", - }); - } else if (availableLanes.length < expectedWorkers) { - worktreeItem = toChecklistItem({ - id: "worktrees", - severity: "warning", - title: "Git worktrees available", - summary: "Worktree capacity is tight for the expected worker fan-out.", - details: [ - `${availableLanes.length} lanes available.`, - `Expected worker parallelism: ~${expectedWorkers}.`, - ], - }); - } else { - worktreeItem = toChecklistItem({ - id: "worktrees", - severity: "pass", - title: "Git worktrees available", - summary: "Worktree capacity is sufficient for expected worker concurrency.", - details: [ - `${availableLanes.length} lanes available.`, - `Expected worker parallelism: ~${expectedWorkers}.`, - ], - }); - } - } catch (error) { - worktreeItem = toChecklistItem({ - id: "worktrees", - severity: "fail", - title: "Git worktrees available", - summary: "Unable to determine lane/worktree capacity.", - details: [error instanceof Error ? error.message : String(error)], - fixHint: "Resolve lane service issues and rerun pre-flight.", - }); - } - checklist.push(worktreeItem); - - const customPhases = selected.phases.filter((phase) => phase.isCustom || phase.phaseKey.startsWith("custom_")); - if (customPhases.length === 0) { - checklist.push( - toChecklistItem({ - id: "phase_semantic", - severity: "pass", - title: "Phase configuration — semantic", - summary: "No custom phase semantic risks detected.", - details: ["No custom phases configured."], - }), - ); - } else { - const semanticWarnings = analyzeCustomPhaseInstructions(customPhases); - - checklist.push( - semanticWarnings.length === 0 - ? toChecklistItem({ - id: "phase_semantic", - severity: "pass", - title: "Phase configuration — semantic", - summary: "Custom phase instructions are actionable.", - details: [ - `${customPhases.length} custom phase(s) evaluated with local checks.`, - "Launch review skips blocking AI semantic calls to keep mission creation responsive.", - ], - }) - : toChecklistItem({ - id: "phase_semantic", - severity: "warning", - title: "Phase configuration — semantic", - summary: "Custom phase instructions may be ambiguous.", - details: semanticWarnings, - fixHint: "Make custom phase instructions specific about expected outputs, constraints, and done criteria.", - }), - ); - } - - const budget = await missionBudgetService.estimateLaunchBudget({ - launch, - selectedPhases: selected.phases, - }); - const forecast = budget.estimate.forecast ?? null; - const budgetDetails = budget.estimate.mode === "subscription" - ? [ - `Mode: ${budget.estimate.mode}`, - `Estimated time: ${summarizeDuration(budget.estimate.estimatedTimeMs)}`, - ...(budget.estimate.actualSpendUsd != null - ? [`Observed spend so far: ${summarizeUsd(budget.estimate.actualSpendUsd)}`] - : []), - ...(budget.estimate.note ? [budget.estimate.note] : []), - ] - : [ - `Estimated: ${summarizeUsd(budget.estimate.estimatedCostUsd)} / ${summarizeDuration(budget.estimate.estimatedTimeMs)}`, - `Mode: ${budget.estimate.mode}`, - ...(budget.estimate.actualSpendUsd != null - ? [`Actual spend so far (window): ${summarizeUsd(budget.estimate.actualSpendUsd)}`] - : []), - ...(budget.estimate.burnRateUsdPerHour != null - ? [`Live burn rate: ${summarizeUsd(budget.estimate.burnRateUsdPerHour)}/hour`] - : []), - ...(forecast - ? [ - `Forecast cost (low/median/high): ${summarizeUsd(forecast.lowCostUsd)} / ${summarizeUsd(forecast.medianCostUsd)} / ${summarizeUsd(forecast.highCostUsd)}`, - `Forecast time (low/median/high): ${summarizeDuration(forecast.lowDurationMs)} / ${summarizeDuration(forecast.medianDurationMs)} / ${summarizeDuration(forecast.highDurationMs)}`, - `Forecast confidence: ${forecast.confidence != null ? `${Math.round(forecast.confidence * 100)}%` : "n/a"} (n=${forecast.sampleSize}, basis: ${forecast.basis})`, - ] - : []), - ...budget.estimate.perPhase.map( - (phase) => `${phase.phaseName}: ${summarizeUsd(phase.estimatedCostUsd)} / ${summarizeDuration(phase.estimatedTimeMs)}`, - ), - ...(budget.estimate.note ? [budget.estimate.note] : []), - ]; - checklist.push( - budget.hardLimitExceeded - ? toChecklistItem({ - id: "budget", - severity: "fail", - title: "Budget", - summary: "Estimated launch cost exceeds available API-key budget envelope.", - details: budgetDetails, - fixHint: "Reduce phase budgets/model cost or increase the configured smart budget threshold.", - }) - : budget.estimate.mode === "subscription" - ? toChecklistItem({ - id: "budget", - severity: "warning", - title: "Budget", - summary: "Subscription mode budget uses observed local CLI telemetry (predictive cost forecast is disabled).", - details: budgetDetails, - }) - : toChecklistItem({ - id: "budget", - severity: "pass", - title: "Budget", - summary: "Estimated launch cost fits within configured API-key budget envelope.", - details: budgetDetails, - }), - ); - - const hardFailures = checklist.filter((item) => item.severity === "fail").length; - const warnings = checklist.filter((item) => item.severity === "warning").length; - const teamRuntimeConfig = launch.teamRuntime ?? launch.executionPolicy?.teamRuntime ?? null; - const selectedLaneLabel = selectedLaneId - ? activeLanes.find((lane) => lane.id === selectedLaneId)?.name ?? null - : null; - const approvalSummary: MissionPreflightResult["approvalSummary"] = { - missionGoal: - toOptionalString(launch.title) - ?? launch.prompt.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) - ?? "Mission", - laneId: selectedLaneId, - laneLabel: selectedLaneLabel, - recommendedExecution: { - orchestratorModelId, - strategy: teamRuntimeConfig?.enabled - ? `Team runtime with ${Math.max(1, teamRuntimeConfig.teammateCount ?? 2)} teammate${Math.max(1, teamRuntimeConfig.teammateCount ?? 2) === 1 ? "" : "s"}` - : selected.phases.length > 1 - ? `Phased execution across ${selected.phases.length} stages` - : "Single-run mission execution", - teamRuntimeEnabled: teamRuntimeConfig?.enabled === true, - teammateCount: teamRuntimeConfig?.enabled === true ? Math.max(1, teamRuntimeConfig.teammateCount ?? 2) : 0, - }, - phaseLabels: selected.phases.map((phase) => phase.name), - validationApproach: selected.phases.map((phase) => { - const gate = phase.validationGate; - const tier = gate.tier === "dedicated" ? "dedicated review" : gate.tier === "self" ? "self-check" : "no formal gate"; - return `${phase.name}: ${gate.required ? "required" : "optional"} ${tier}`; - }), - conflictAssumptions: [ - selectedLaneId - ? `Primary mission lane: ${selectedLaneLabel ?? selectedLaneId}.` - : "Mission will attach to the default active lane if no explicit lane is selected.", - worktreeItem.severity === "warning" - ? "Parallel fan-out may be reduced because available worktrees are below the ideal concurrency target." - : "ADE can provision or reuse enough active lanes for the expected worker fan-out.", - "Mission closeout always ends with a result lane that contains the consolidated changes. Multi-lane work is joined through ADE's internal integration lane, and ADE will not auto-open an external GitHub PR during mission finalization.", - ], - knownBlockers: checklist - .filter((item) => item.severity === "fail") - .flatMap((item) => [item.summary, ...item.details]) - .slice(0, 8), - }; - - return { - canLaunch: hardFailures === 0, - checkedAt: nowIso(), - profileName: selected.profile?.name ?? null, - selectedPhaseCount: selected.phases.length, - hardFailures, - warnings, - checklist, - budgetEstimate: budget.estimate, - approvalSummary, - computerUse: { - requiredKinds: requiredComputerUseKinds, - missingKinds: blockingMissingComputerUseKinds, - availableExternalBackends, - blocked: computerUseBlocked, - summary: requiredComputerUseKinds.length === 0 - ? "This mission does not require computer-use proof." - : computerUseBlocked - ? `Required proof coverage is missing for ${blockingMissingComputerUseKinds.join(", ") || requiredComputerUseKinds.join(", ")}.` - : warningMissingComputerUseKinds.length > 0 - ? `Warning-only proof coverage is missing for ${warningMissingComputerUseKinds.join(", ")}.` - : fallbackOnlyKinds.length > 0 - ? `Required proof is covered, but ${fallbackOnlyKinds.join(", ")} still rely on ADE-local fallback support.` - : `Required proof can be satisfied through approved external backends: ${availableExternalBackends.join(", ")}.`, - }, - }; - }; - - return { - runPreflight, - }; -} - -export type MissionPreflightService = ReturnType; diff --git a/apps/desktop/src/main/services/missions/missionService.test.ts b/apps/desktop/src/main/services/missions/missionService.test.ts deleted file mode 100644 index 6f3b055e1..000000000 --- a/apps/desktop/src/main/services/missions/missionService.test.ts +++ /dev/null @@ -1,1065 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { describe, expect, it, vi } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createMissionService } from "./missionService"; -import type { PhaseCard } from "../../../shared/types"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -async function createDbWithProjectAndLane() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mission-service-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - - const projectId = "proj-1"; - const laneId = "lane-1"; - const now = "2026-02-18T00:00:00.000Z"; - - db.run( - ` - insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?) - `, - [projectId, root, "ADE", "main", now, now] - ); - - db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - laneId, - projectId, - "Lane 1", - null, - "worktree", - "main", - "feature/lane-1", - root, - null, - 0, - null, - null, - null, - null, - "active", - now, - null - ] - ); - - return { - db, - dbPath, - projectId, - laneId, - root, - dispose: () => db.close() - }; -} - -describe("missionService lifecycle", () => { - it("supports valid mission lifecycle transitions", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Implement profile tab improvements and prepare PR summary.", - laneId, - plannedSteps: [ - { index: 0, title: "Implement profile tab", detail: "Profile tab improvements", kind: "task", metadata: { stepType: "implementation" } }, - { index: 1, title: "Prepare PR summary", detail: "PR summary", kind: "integration", metadata: { stepType: "integration" } } - ] - }); - - expect(created.status).toBe("queued"); - expect(created.steps.length).toBeGreaterThan(0); - - const started = service.update({ missionId: created.id, status: "in_progress" }); - expect(started.status).toBe("in_progress"); - - const intervention = service.addIntervention({ - missionId: created.id, - interventionType: "manual_input", - title: "Need design confirmation", - body: "Confirm whether we should preserve the old spacing scale." - }); - - expect(intervention.status).toBe("open"); - - const paused = service.get(created.id); - expect(paused?.status).toBe("intervention_required"); - expect(paused?.openInterventions).toBe(1); - - service.resolveIntervention({ - missionId: created.id, - interventionId: intervention.id, - status: "resolved", - note: "Proceed with the new spacing scale." - }); - - const resumed = service.get(created.id); - expect(resumed?.status).toBe("in_progress"); - expect(resumed?.openInterventions).toBe(0); - - const completed = service.update({ - missionId: created.id, - status: "completed", - outcomeSummary: "Updated profile layout and linked PR #123." - }); - - expect(completed.status).toBe("completed"); - expect(completed.outcomeSummary).toContain("PR #123"); - expect(completed.artifacts.some((artifact) => artifact.artifactType === "summary")).toBe(true); - - dispose(); - }); - - it("normalizes loose planned steps before persisting them", async () => { - const { db, projectId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Create a loose planned-step fixture.", - plannedSteps: [ - { title: "Implement fixture", kind: "implementation", metadata: { stepType: "implementation" } } as any, - { index: 2, title: "Run tests", detail: "Verify", kind: "test", metadata: { stepType: "test" } } as any, - ], - }); - - expect(created.steps).toHaveLength(2); - expect(created.steps[0]?.title).toBe("Implement fixture"); - expect(created.steps[0]?.detail).toBe(""); - expect(created.steps[0]?.kind).toBe("implementation"); - expect(created.steps[1]?.detail).toBe("Verify"); - - dispose(); - }); - - it("keeps mission active for scoped manual-input interventions", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Coordinate workers and ask for optional user guidance when needed.", - laneId - }); - - service.update({ missionId: created.id, status: "in_progress" }); - service.addIntervention({ - missionId: created.id, - interventionType: "manual_input", - title: "Clarify API naming", - body: "Worker requested optional naming guidance.", - pauseMission: false - }); - - const detail = service.get(created.id); - expect(detail?.status).toBe("in_progress"); - expect(detail?.openInterventions).toBe(1); - - dispose(); - }); - - it("emits the resolved intervention through the resolution hook", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const onInterventionResolved = vi.fn(); - const service = createMissionService({ db, projectId, onInterventionResolved }); - - const created = service.create({ - prompt: "Wait for user guidance before resuming work.", - laneId, - }); - - service.update({ missionId: created.id, status: "in_progress" }); - const intervention = service.addIntervention({ - missionId: created.id, - interventionType: "manual_input", - title: "Clarify rollout rule", - body: "Do we keep the old rollout guard?", - }); - - service.resolveIntervention({ - missionId: created.id, - interventionId: intervention.id, - status: "resolved", - note: "Keep the old rollout guard for now.", - }); - - expect(onInterventionResolved).toHaveBeenCalledTimes(1); - expect(onInterventionResolved).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: created.id, - intervention: expect.objectContaining({ - id: intervention.id, - status: "resolved", - resolutionNote: "Keep the old rollout guard for now.", - }), - }), - ); - - dispose(); - }); - - it("persists explicit planned steps with metadata", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Implement auth orchestration end-to-end.", - laneId, - plannedSteps: [ - { index: 0, title: "Refactor token middleware", detail: "Refactor", kind: "task", metadata: { stepType: "implementation", doneCriteria: "Middleware refactored" } }, - { index: 1, title: "Add route guards", detail: "Guards", kind: "task", metadata: { stepType: "implementation", doneCriteria: "Guards added", dependencyIndices: [0] } }, - { index: 2, title: "Run tests", detail: "Tests", kind: "validation", metadata: { stepType: "test", doneCriteria: "Tests pass", dependencyIndices: [0, 1] } }, - { index: 3, title: "Summarize and integrate", detail: "Integration", kind: "integration", metadata: { stepType: "integration", doneCriteria: "PR ready", dependencyIndices: [2] } } - ] - }); - - expect(created.steps.length).toBeGreaterThanOrEqual(4); - expect(created.steps.some((step) => step.kind === "integration")).toBe(true); - expect( - created.steps.every( - (step) => typeof step.metadata?.doneCriteria === "string" && String(step.metadata?.doneCriteria).trim().length > 0 - ) - ).toBe(true); - expect( - created.steps.some( - (step) => Array.isArray(step.metadata?.dependencyIndices) && (step.metadata?.dependencyIndices as unknown[]).length > 0 - ) - ).toBe(true); - - dispose(); - }); - - it("flushes newly created missions to disk immediately", async () => { - const { db, dbPath, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Persist mission rows before a quick app restart.", - laneId - }); - - const reopened = await openKvDb(dbPath, createLogger()); - const persisted = reopened.get<{ count: number }>( - "select count(*) as count from missions where id = ? and project_id = ?", - [created.id, projectId] - ); - - expect(Number(persisted?.count ?? 0)).toBe(1); - - reopened.close(); - dispose(); - }); - - it("maps legacy plan_review lifecycle rows to in_progress in active mission listings", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Design orchestration plan and await approval before execution.", - laneId - }); - - const planning = service.update({ missionId: created.id, status: "planning" }); - expect(planning.status).toBe("planning"); - const activePlanning = service.list({ status: "active" }); - expect(activePlanning.some((entry) => entry.id === created.id && entry.status === "planning")).toBe(true); - - db.run( - "update missions set status = 'plan_review' where id = ? and project_id = ?", - [created.id, projectId] - ); - - const detail = service.get(created.id); - expect(detail?.status).toBe("in_progress"); - - const activeReviewCompat = service.list({ status: "active" }); - expect(activeReviewCompat.some((entry) => entry.id === created.id && entry.status === "in_progress")).toBe(true); - - const running = service.update({ missionId: created.id, status: "in_progress" }); - expect(running.status).toBe("in_progress"); - - dispose(); - }); - - it("rejects invalid mission and step transitions", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Write migration notes and close release lane.", - laneId, - plannedSteps: [ - { index: 0, title: "Write migration notes", detail: "Notes", kind: "task", metadata: { stepType: "implementation" } } - ] - }); - - expect(() => service.update({ missionId: created.id, status: "completed" })).toThrow(/Invalid mission transition/i); - - const firstStep = service.get(created.id)?.steps[0]; - expect(firstStep).toBeTruthy(); - if (!firstStep) { - dispose(); - throw new Error("Expected first step"); - } - - expect(() => - service.updateStep({ - missionId: created.id, - stepId: firstStep.id, - status: "succeeded" - }) - ).toThrow(/Invalid mission step transition/i); - - dispose(); - }); - - it("creates intervention-required status when a running step fails", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const created = service.create({ - prompt: "Refactor auth checks and run regression tests.", - laneId, - plannedSteps: [ - { index: 0, title: "Refactor auth checks", detail: "Auth refactor", kind: "task", metadata: { stepType: "implementation" } } - ] - }); - - service.update({ missionId: created.id, status: "in_progress" }); - - const firstStep = service.get(created.id)?.steps[0]; - expect(firstStep).toBeTruthy(); - if (!firstStep) { - dispose(); - throw new Error("Expected first step"); - } - - service.updateStep({ - missionId: created.id, - stepId: firstStep.id, - status: "running" - }); - - service.updateStep({ - missionId: created.id, - stepId: firstStep.id, - status: "failed", - note: "Unit tests failed in CI parity suite." - }); - - const detail = service.get(created.id); - expect(detail?.status).toBe("intervention_required"); - expect(detail?.openInterventions).toBeGreaterThan(0); - expect(detail?.lastError).toContain("Unit tests failed"); - - dispose(); - }); - - it("counts intervention-required missions against the concurrency cap", async () => { - const { db, projectId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ - db, - projectId, - concurrencyConfig: { maxConcurrentMissions: 1 }, - }); - - const blockedMission = service.create({ prompt: "Wait for user input" }); - service.update({ missionId: blockedMission.id, status: "in_progress" }); - service.update({ missionId: blockedMission.id, status: "intervention_required" }); - - const queuedMission = service.create({ prompt: "Start after the blocked mission resumes" }); - const startCheck = service.canStartMission(queuedMission.id); - - expect(startCheck.allowed).toBe(false); - expect(startCheck.reason).toContain("1 missions already active"); - - dispose(); - }); - - it("deletes mission records and orchestrator runtime dependents", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - const created = service.create({ - prompt: "Ship auth hardening changes and open PR.", - laneId, - plannedSteps: [ - { index: 0, title: "Ship auth hardening", detail: "Auth hardening", kind: "task", metadata: { stepType: "implementation" } } - ] - }); - const firstStep = created.steps[0]; - if (!firstStep) { - dispose(); - throw new Error("Expected mission step"); - } - - const now = "2026-02-19T00:00:00.000Z"; - const runId = randomUUID(); - const orchestratorStepId = randomUUID(); - const snapshotId = randomUUID(); - const attemptId = randomUUID(); - const claimId = randomUUID(); - - db.run( - ` - insert into orchestrator_runs( - id, - project_id, - mission_id, - status, - context_profile, - scheduler_state, - runtime_cursor_json, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, 'running', 'orchestrator_deterministic_v1', 'manual', null, null, null, ?, ?, ?, null) - `, - [runId, projectId, created.id, now, now, now] - ); - db.run( - ` - insert into orchestrator_steps( - id, - run_id, - project_id, - mission_step_id, - step_key, - step_index, - title, - lane_id, - status, - join_policy, - quorum_count, - dependency_step_ids_json, - retry_limit, - retry_count, - last_attempt_id, - policy_json, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, 'mission_step_0_0', 0, 'Review objective', ?, 'running', 'all_success', null, '[]', 1, 0, null, null, null, ?, ?, ?, null) - `, - [orchestratorStepId, runId, projectId, firstStep.id, laneId, now, now, now] - ); - db.run( - ` - insert into orchestrator_context_snapshots( - id, - project_id, - run_id, - step_id, - attempt_id, - snapshot_type, - context_profile, - cursor_json, - created_at - ) values (?, ?, ?, ?, null, 'attempt', 'orchestrator_deterministic_v1', '{"docs":[]}', ?) - `, - [snapshotId, projectId, runId, orchestratorStepId, now] - ); - db.run( - ` - insert into orchestrator_attempts( - id, - run_id, - step_id, - project_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - ) values (?, ?, ?, ?, 1, 'running', 'manual', null, 1, 'orchestrator_deterministic_v1', ?, 'none', null, 0, null, null, ?, ?, null) - `, - [attemptId, runId, orchestratorStepId, projectId, snapshotId, now, now] - ); - db.run( - ` - insert into orchestrator_claims( - id, - project_id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - ) values (?, ?, ?, ?, ?, 'owner', 'lane', ?, 'active', ?, ?, ?, null, '{"ttlMs":45000}', null) - `, - [claimId, projectId, runId, orchestratorStepId, attemptId, laneId, now, now, "2026-02-19T00:10:00.000Z"] - ); - db.run( - ` - insert into mission_step_handoffs( - id, - project_id, - mission_id, - mission_step_id, - run_id, - step_id, - attempt_id, - handoff_type, - producer, - payload_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, 'attempt_started', 'orchestrator', '{}', ?) - `, - [randomUUID(), projectId, created.id, firstStep.id, runId, orchestratorStepId, attemptId, now] - ); - db.run( - ` - insert into orchestrator_timeline_events( - id, - project_id, - run_id, - step_id, - attempt_id, - claim_id, - event_type, - reason, - detail_json, - created_at - ) values (?, ?, ?, ?, ?, ?, 'attempt_started', 'test', null, ?) - `, - [randomUUID(), projectId, runId, orchestratorStepId, attemptId, claimId, now] - ); - db.run( - "update lanes set mission_id = ?, lane_role = 'result' where id = ? and project_id = ?", - [created.id, laneId, projectId] - ); - - service.delete({ missionId: created.id }); - - const mission = service.get(created.id); - expect(mission).toBeNull(); - expect(db.get<{ count: number }>("select count(*) as count from orchestrator_runs where mission_id = ?", [created.id])?.count ?? 0).toBe(0); - expect(db.get<{ count: number }>("select count(*) as count from mission_steps where mission_id = ?", [created.id])?.count ?? 0).toBe(0); - expect( - db.get<{ count: number }>("select count(*) as count from mission_step_handoffs where mission_id = ?", [created.id])?.count ?? 0 - ).toBe(0); - expect( - db.get<{ count: number }>("select count(*) as count from orchestrator_timeline_events where run_id = ?", [runId])?.count ?? 0 - ).toBe(0); - const lane = db.get<{ mission_id: string | null; lane_role: string | null }>( - "select mission_id, lane_role from lanes where id = ? and project_id = ?", - [laneId, projectId] - ); - expect(lane?.mission_id).toBeNull(); - expect(lane?.lane_role).toBeNull(); - - dispose(); - }); - - it("logs when clearing stale lane mission claims", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const service = createMissionService({ db, projectId, logger }); - - db.run("pragma foreign_keys = off"); - try { - db.run( - "update lanes set mission_id = ?, lane_role = 'mission' where id = ? and project_id = ?", - ["missing-mission", laneId, projectId] - ); - } finally { - db.run("pragma foreign_keys = on"); - } - - expect(service.isLaneClaimed(laneId).claimed).toBe(false); - expect(logger.info).toHaveBeenCalledWith("missions.lane_ghost_claim_cleared", { - projectId, - laneId, - staleMissionId: "missing-mission", - }); - const lane = db.get<{ mission_id: string | null; lane_role: string | null }>( - "select mission_id, lane_role from lanes where id = ? and project_id = ?", - [laneId, projectId] - ); - expect(lane?.mission_id).toBeNull(); - expect(lane?.lane_role).toBeNull(); - - dispose(); - }); - - it("supports phase profile CRUD and import/export", async () => { - const { db, projectId, root, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId, projectRoot: root }); - - const seeded = service.listPhaseProfiles(); - expect(seeded.length).toBeGreaterThanOrEqual(2); - const defaultProfile = seeded.find((profile) => profile.isDefault); - expect(defaultProfile).toBeTruthy(); - - const clone = service.clonePhaseProfile({ - profileId: defaultProfile!.id, - name: "Security Focused" - }); - expect(clone.isBuiltIn).toBe(false); - expect(clone.name).toBe("Security Focused"); - - const exported = service.exportPhaseProfile({ profileId: clone.id }); - expect(exported.profile.id).toBe(clone.id); - expect(exported.savedPath).toBeTruthy(); - - const imported = service.importPhaseProfile({ - filePath: exported.savedPath!, - setAsDefault: true - }); - expect(imported.isDefault).toBe(true); - - const proofProfile = service.savePhaseProfile({ - profile: { - ...defaultProfile!, - id: "proof-profile", - name: "Proof Required", - isDefault: false, - phases: defaultProfile!.phases.map((phase) => - phase.phaseKey === "testing" || phase.phaseKey === "validation" - ? { - ...phase, - validationGate: { - ...phase.validationGate, - evidenceRequirements: ["screenshot"], - capabilityFallback: "warn", - }, - } - : phase - ), - }, - }); - const proofPhase = proofProfile.phases.find((phase) => - phase.phaseKey === "testing" || phase.phaseKey === "validation" - ); - expect(proofPhase?.validationGate.evidenceRequirements).toEqual(["screenshot"]); - expect(proofPhase?.validationGate.capabilityFallback).toBe("warn"); - - const profiles = service.listPhaseProfiles(); - const defaultCount = profiles.filter((profile) => profile.isDefault).length; - expect(defaultCount).toBe(1); - - dispose(); - }); - - it("persists mission phase configuration and annotates step metadata with phase context", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - const profiles = service.listPhaseProfiles(); - const defaultProfile = profiles.find((profile) => profile.isDefault); - expect(defaultProfile).toBeTruthy(); - - const created = service.create({ - prompt: "Ship auth migration", - laneId, - phaseProfileId: defaultProfile!.id, - phaseOverride: defaultProfile!.phases.map((phase, index) => ({ - ...phase, - position: index - })) - }); - - expect(created.phaseConfiguration).toBeTruthy(); - expect(created.phaseConfiguration?.selectedPhases.length).toBeGreaterThan(0); - expect( - created.steps.every((step) => typeof step.metadata?.phaseKey === "string" && String(step.metadata?.phaseKey).length > 0) - ).toBe(true); - - const cfg = service.getPhaseConfiguration(created.id); - expect(cfg?.override?.phases.length).toBeGreaterThan(0); - expect(cfg?.profile?.id).toBe(defaultProfile!.id); - - dispose(); - }); - - it("runs a sample mission configuration with custom Codex-only phases", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - const defaultProfile = service.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default profile"); - - const now = "2026-03-25T00:00:00.000Z"; - const codexModel = { provider: "codex" as const, modelId: "openai/gpt-5.5", thinkingLevel: "medium" as const }; - const basePhases = defaultProfile.phases.map((phase, index) => ({ - ...phase, - model: codexModel, - position: index, - })); - const closeoutPhase = basePhases.find((phase) => phase.phaseKey === "closeout"); - if (!closeoutPhase) throw new Error("Expected closeout phase"); - - const reviewPhase: PhaseCard = { - id: "custom:review", - phaseKey: "review", - name: "Review", - description: "Review mission output before closeout.", - instructions: "Audit the result lane and call out missing proof or regressions before closeout.", - model: codexModel, - budget: {}, - orderingConstraints: { mustFollow: ["validation"] }, - askQuestions: { enabled: false }, - validationGate: { - tier: "dedicated", - required: true, - criteria: "Reviewer confirms the result matches the mission goal.", - evidenceRequirements: ["review_summary", "risk_notes"], - capabilityFallback: "warn", - }, - isBuiltIn: false, - isCustom: true, - position: 0, - createdAt: now, - updatedAt: now, - }; - const slashCommandPhase: PhaseCard = { - ...reviewPhase, - id: "custom:slash-command", - phaseKey: "slash_command", - name: "Slash command", - description: "Run a scripted follow-up command.", - instructions: "Execute the requested slash-command style workflow and return structured output.", - orderingConstraints: { mustFollow: ["review"] }, - validationGate: { - tier: "self", - required: true, - criteria: "Command output is captured before closeout.", - evidenceRequirements: ["final_outcome_summary"], - capabilityFallback: "warn", - }, - }; - const phaseOverride = [ - ...basePhases.filter((phase) => phase.phaseKey !== "closeout"), - reviewPhase, - slashCommandPhase, - closeoutPhase, - ].map((phase, index) => ({ ...phase, position: index })); - - const created = service.create({ - prompt: "Sample backend mission using Codex planning, custom review, slash command, integration, and closeout.", - laneId, - phaseProfileId: defaultProfile.id, - phaseOverride, - plannedSteps: [ - { index: 0, title: "Plan work", detail: "Planning pass", kind: "task", metadata: { stepType: "planning" } }, - { index: 1, title: "Implement work", detail: "Implementation pass", kind: "task", metadata: { stepType: "implementation" } }, - { index: 2, title: "Integrate lanes", detail: "Merge worker output", kind: "integration", metadata: { stepType: "integration" } }, - { index: 3, title: "Run tests", detail: "Targeted verification", kind: "task", metadata: { stepType: "test" } }, - { index: 4, title: "Validate result", detail: "Dedicated validation", kind: "validation", metadata: { stepType: "milestone" } }, - { index: 5, title: "Review output", detail: "Review result lane", kind: "task", metadata: { phaseKey: "review" } }, - { index: 6, title: "Run slash command", detail: "Run slash workflow", kind: "task", metadata: { phaseKey: "slash_command" } }, - { index: 7, title: "Close out", detail: "Summarize result lane", kind: "summary", metadata: { stepType: "closeout" } }, - ], - }); - - const selectedPhases = created.phaseConfiguration?.selectedPhases ?? []; - expect(selectedPhases.map((phase) => phase.phaseKey)).toEqual([ - "planning", - "development", - "integration", - "testing", - "validation", - "review", - "slash_command", - "closeout", - ]); - for (const phase of selectedPhases) { - expect(phase.model).toMatchObject({ provider: "codex", modelId: "openai/gpt-5.5" }); - } - expect(selectedPhases.find((phase) => phase.phaseKey === "review")?.validationGate.evidenceRequirements).toEqual([ - "review_summary", - "risk_notes", - ]); - - const phaseKeyForStep = (title: string) => { - const step = created.steps.find((entry) => entry.title === title); - if (!step) throw new Error(`Missing step ${title}`); - return (step.metadata ?? {}).phaseKey; - }; - expect(phaseKeyForStep("Plan work")).toBe("planning"); - expect(phaseKeyForStep("Implement work")).toBe("development"); - expect(phaseKeyForStep("Integrate lanes")).toBe("integration"); - expect(phaseKeyForStep("Run tests")).toBe("testing"); - expect(phaseKeyForStep("Validate result")).toBe("validation"); - expect(phaseKeyForStep("Review output")).toBe("review"); - expect(phaseKeyForStep("Run slash command")).toBe("slash_command"); - expect(phaseKeyForStep("Close out")).toBe("closeout"); - - dispose(); - }); - - it("normalizes question and validation settings to planning-only semantics", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - const profiles = service.listPhaseProfiles(); - const defaultProfile = profiles.find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default profile"); - - const created = service.create({ - prompt: "Normalize phase semantics", - laneId, - phaseProfileId: defaultProfile.id, - phaseOverride: defaultProfile.phases.map((phase, index) => ({ - ...phase, - position: index, - askQuestions: phase.phaseKey === "development" - ? { enabled: true, maxQuestions: 9 } - : phase.phaseKey === "planning" - ? { ...phase.askQuestions, requiredBeforeExit: true } - : phase.askQuestions, - validationGate: phase.phaseKey === "planning" - ? { tier: "self", required: true, criteria: "Should be removed" } - : phase.validationGate, - })), - }); - - const planning = created.phaseConfiguration?.selectedPhases.find((phase) => phase.phaseKey === "planning"); - const development = created.phaseConfiguration?.selectedPhases.find((phase) => phase.phaseKey === "development"); - expect(planning?.validationGate.tier).toBe("none"); - expect(planning?.validationGate.required).toBe(false); - expect(planning?.askQuestions.requiredBeforeExit).toBe(true); - expect(planning?.requiresApproval).toBe(true); - expect(development?.askQuestions.enabled).toBe(false); - expect(development?.askQuestions.maxQuestions).toBeUndefined(); - - dispose(); - }); - - it("preserves ask-question settings on custom phases", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - const defaultProfile = service.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default profile"); - - const now = "2026-03-25T00:00:00.000Z"; - const customPhase: PhaseCard = { - id: "custom:discovery", - phaseKey: "discovery_requirements", - name: "Discovery / Requirements", - description: "Clarify requirements before development.", - instructions: "Ask blocking questions, produce a requirements brief, and report risks before development.", - model: defaultProfile.phases[0]!.model, - budget: {}, - orderingConstraints: { mustFollow: ["planning"], mustPrecede: ["development"] }, - askQuestions: { enabled: true, maxQuestions: 5, requiredBeforeExit: true }, - validationGate: { tier: "none", required: false }, - isBuiltIn: false, - isCustom: true, - position: 1, - createdAt: now, - updatedAt: now, - }; - const phaseOverride = [ - defaultProfile.phases.find((phase) => phase.phaseKey === "planning")!, - customPhase, - ...defaultProfile.phases.filter((phase) => phase.phaseKey !== "planning"), - ].map((phase, index) => ({ ...phase, position: index })); - - const created = service.create({ - prompt: "Build a launch dashboard after discovery.", - laneId, - phaseProfileId: defaultProfile.id, - phaseOverride, - }); - - const selected = created.phaseConfiguration?.selectedPhases ?? []; - const persistedCustom = selected.find((phase) => phase.phaseKey === "discovery_requirements"); - expect(selected.map((phase) => phase.phaseKey).slice(0, 3)).toEqual([ - "planning", - "discovery_requirements", - "development", - ]); - expect(persistedCustom?.askQuestions).toEqual({ - enabled: true, - maxQuestions: 5, - requiredBeforeExit: true, - }); - - dispose(); - }); - - it("returns mission dashboard snapshots for active/recent/weekly views", async () => { - const { db, projectId, laneId, dispose } = await createDbWithProjectAndLane(); - const service = createMissionService({ db, projectId }); - - const activeMission = service.create({ - prompt: "Implement active mission", - laneId - }); - service.update({ missionId: activeMission.id, status: "in_progress" }); - const runId = randomUUID(); - const now = "2026-02-19T00:00:00.000Z"; - db.run( - ` - insert into orchestrator_runs( - id, - project_id, - mission_id, - status, - context_profile, - scheduler_state, - runtime_cursor_json, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, 'active', 'orchestrator_deterministic_v1', 'manual', null, null, ?, ?, ?, ?, null) - `, - [ - runId, - projectId, - activeMission.id, - JSON.stringify({ phaseRuntime: { currentPhaseName: "Development" } }), - now, - now, - now, - ] - ); - for (const [index, step] of [ - { - key: "planning-worker", - title: "Planning worker", - status: "succeeded", - metadata: { phaseName: "Planning", stepType: "planning" }, - }, - { - key: "display-task", - title: "Display task", - status: "pending", - metadata: { stepType: "task" }, - }, - { - key: "development-worker", - title: "Development worker", - status: "succeeded", - metadata: { phaseName: "Development", stepType: "implementation" }, - }, - ].entries()) { - db.run( - ` - insert into orchestrator_steps( - id, - run_id, - project_id, - mission_step_id, - step_key, - step_index, - title, - lane_id, - status, - join_policy, - quorum_count, - dependency_step_ids_json, - retry_limit, - retry_count, - last_attempt_id, - policy_json, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, null, ?, ?, ?, ?, ?, 'all_success', null, '[]', 1, 0, null, null, ?, ?, ?, ?, ?) - `, - [ - randomUUID(), - runId, - projectId, - step.key, - index, - step.title, - laneId, - step.status, - JSON.stringify(step.metadata), - now, - now, - now, - step.status === "succeeded" ? now : null, - ] - ); - } - - const actionMission = service.create({ - prompt: "Implement blocked mission", - laneId - }); - service.update({ missionId: actionMission.id, status: "in_progress" }); - service.update({ missionId: actionMission.id, status: "intervention_required" }); - - const completedMission = service.create({ - prompt: "Implement completed mission", - laneId - }); - service.update({ missionId: completedMission.id, status: "in_progress" }); - service.update({ missionId: completedMission.id, status: "completed" }); - - const snapshot = service.getDashboard(); - const activeEntry = snapshot.active.find((entry) => entry.mission.id === activeMission.id); - expect(activeEntry).toBeTruthy(); - expect(activeEntry?.phaseName).toBe("Development"); - expect(activeEntry?.phaseProgress).toEqual({ completed: 2, total: 2, pct: 100 }); - expect(snapshot.active.some((entry) => entry.mission.id === actionMission.id)).toBe(true); - expect(snapshot.recent.some((entry) => entry.mission.id === completedMission.id)).toBe(true); - expect(snapshot.weekly.missions).toBeGreaterThanOrEqual(2); - - dispose(); - }); -}); diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts deleted file mode 100644 index 9033ede99..000000000 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ /dev/null @@ -1,4541 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { - isValidResolutionKind, - TERMINAL_MISSION_STATUSES, -} from "../../../shared/types"; -import type { - AddMissionArtifactArgs, - AddMissionInterventionArgs, - ClonePhaseProfileArgs, - CreateMissionArgs, - DeletePhaseProfileArgs, - DeletePhaseItemArgs, - ExportPhaseItemsArgs, - ExportPhaseItemsResult, - GetMissionEventsArgs, - ImportPhaseProfileArgs, - ImportPhaseItemsArgs, - ListPhaseItemsArgs, - ListPhaseProfilesArgs, - MissionConcurrencyCheckResult, - MissionConcurrencyConfig, - MissionDashboardSnapshot, - MissionExecutionPolicy, - MissionLaneClaimCheckResult, - MissionPhaseConfiguration, - MissionPhaseOverride, - ListMissionsArgs, - MissionArtifact, - MissionArtifactType, - MissionDetail, - MissionDetailWarning, - MissionEvent, - MissionEventsPage, - MissionExecutionMode, - MissionIntervention, - MissionInterventionResolutionKind, - MissionInterventionStatus, - MissionInterventionType, - MissionPriority, - MissionsEventPayload, - MissionStatus, - MissionStep, - MissionStepStatus, - MissionSummary, - ThinkingLevel, - PhaseCard, - PhaseProfile, - ValidationCapabilityFallbackPolicy, - ValidationEvidenceRequirement, - SavePhaseItemArgs, - SavePhaseProfileArgs, - ExportPhaseProfileArgs, - ExportPhaseProfileResult, - PlannerPlan, - PlannerClarifyingQuestion, - PlannerClarifyingAnswer, - ResolveMissionInterventionArgs, - ArchiveMissionArgs, - DeleteMissionArgs, - UpdateMissionArgs, - UpdateMissionStepArgs -} from "../../../shared/types"; -import type { AdeDb } from "../state/kvDb"; -import type { Logger } from "../logging/logger"; -type MissionPlanStepDraft = { - index: number; - title: string; - detail: string; - kind: string; - metadata: Record; -}; -import { - applyPhaseCardsToPlanSteps, - createBuiltInPhaseCards, - createBuiltInPhaseProfiles, - ensurePlanningPhase, - normalizeProfileInput, - validatePhaseSequence, - groupMissionStepsByPhase, -} from "./phaseEngine"; -import { isRecord, nowIso, safeJsonParse } from "../shared/utils"; -import { normalizeAgentRuntimeFlags } from "../orchestrator/teamRuntimeConfig"; -import { filterExecutionSteps, TERMINAL_STEP_STATUSES } from "../orchestrator/orchestratorContext"; -import { resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { normalizeMissionArtifactType as normalizeMissionArtifactTypeValue } from "../../../shared/proofArtifacts"; - -const ACTIVE_MISSION_STATUSES = new Set(["in_progress", "planning", "intervention_required"]); - -const DEFAULT_CONCURRENCY_CONFIG: MissionConcurrencyConfig = { - maxConcurrentMissions: 3, - laneExclusivity: true -}; - -const PRIORITY_ORDER: Record = { - urgent: 0, - high: 1, - normal: 2, - low: 3 -}; - -const MISSION_TRANSITIONS: Record> = { - queued: new Set(["queued", "planning", "in_progress", "canceled"]), - planning: new Set(["planning", "in_progress", "intervention_required", "failed", "canceled", "queued"]), - in_progress: new Set(["in_progress", "intervention_required", "completed", "failed", "canceled"]), - intervention_required: new Set(["intervention_required", "in_progress", "failed", "canceled"]), - completed: new Set(["completed", "queued"]), - failed: new Set(["failed", "queued", "planning", "in_progress", "canceled"]), - canceled: new Set(["canceled", "queued", "planning", "in_progress"]) -}; - -const STEP_TRANSITIONS: Record> = { - pending: new Set(["pending", "running", "skipped", "blocked", "canceled"]), - running: new Set(["running", "succeeded", "failed", "blocked", "canceled"]), - blocked: new Set(["blocked", "running", "failed", "canceled", "skipped"]), - succeeded: new Set(["succeeded"]), - failed: new Set(["failed", "running", "canceled"]), - skipped: new Set(["skipped"]), - canceled: new Set(["canceled"]) -}; - -type MissionRow = { - id: string; - title: string; - prompt: string; - lane_id: string | null; - lane_name: string | null; - mission_lane_id: string | null; - mission_lane_name: string | null; - result_lane_id: string | null; - result_lane_name: string | null; - status: string; - priority: string; - execution_mode: string; - target_machine_id: string | null; - outcome_summary: string | null; - last_error: string | null; - metadata_json?: string | null; - artifact_count: number; - open_interventions: number; - total_steps: number; - completed_steps: number; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; - queue_claim_token?: string | null; - queue_claimed_at?: string | null; - archived_at?: string | null; -}; - -type MissionStepRow = { - id: string; - mission_id: string; - step_index: number; - title: string; - detail: string | null; - kind: string; - lane_id: string | null; - status: string; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; - metadata_json: string | null; -}; - -type DashboardOrchestratorRunRow = { - id: string; - mission_id: string; - metadata_json: string | null; -}; - -type DashboardOrchestratorStep = { - runId: string; - status: string; - metadata: Record | null; -}; - -type MissionEventRow = { - id: string; - mission_id: string; - event_type: string; - actor: string; - summary: string; - payload_json: string | null; - created_at: string; -}; - -type MissionArtifactRow = { - id: string; - mission_id: string; - artifact_type: string; - title: string; - description: string | null; - uri: string | null; - lane_id: string | null; - created_by: string; - created_at: string; - updated_at: string; - metadata_json: string | null; -}; - -type MissionInterventionRow = { - id: string; - mission_id: string; - intervention_type: string; - status: string; - resolution_kind: string | null; - title: string; - body: string; - requested_action: string | null; - resolution_note: string | null; - lane_id: string | null; - created_at: string; - updated_at: string; - resolved_at: string | null; - metadata_json: string | null; -}; - -type PhaseProfileRow = { - id: string; - project_id: string; - name: string; - description: string; - phases_json: string; - is_built_in: number; - is_default: number; - created_at: string; - updated_at: string; - archived_at: string | null; -}; - -type PhaseCardRow = { - id: string; - project_id: string; - phase_key: string; - name: string; - description: string; - instructions: string; - model_json: string; - budget_json: string | null; - ordering_constraints_json: string | null; - ask_questions_json: string | null; - validation_gate_json: string | null; - is_built_in: number; - is_custom: number; - position: number; - archived_at: string | null; - created_at: string; - updated_at: string; -}; - -type MissionPhaseOverrideRow = { - id: string; - mission_id: string; - profile_id: string | null; - phases_json: string; - created_at: string; - updated_at: string; -}; - -type CreateMissionInternalArgs = CreateMissionArgs & { - plannedSteps?: MissionPlanStepDraft[]; - plannerPlan?: PlannerPlan | null; -}; - -const MISSION_EVENTS_PAGE_LIMIT = 200; -const MISSION_QUEUED_CLAIM_STALE_MS = 2 * 60 * 1000; -const MISSION_START_MANUAL_TOKEN = "__manual__"; - -function safeParseRecord(raw: string | null): Record | null { - const parsed = safeJsonParse(raw, null); - return isRecord(parsed) ? parsed : null; -} - -function safeParseArray(raw: string | null): unknown[] { - const parsed = safeJsonParse(raw, null); - return Array.isArray(parsed) ? parsed : []; -} - -function parseMissionCursor(cursor: string | null | undefined): { createdAt: string; id: string } | null { - if (typeof cursor !== "string") return null; - const trimmed = cursor.trim(); - if (!trimmed.length) return null; - const separator = trimmed.indexOf("::"); - if (separator <= 0 || separator === trimmed.length - 2) return null; - return { - createdAt: trimmed.slice(0, separator), - id: trimmed.slice(separator + 2), - }; -} - -function encodeMissionCursor(event: MissionEvent | MissionEventRow): string { - return `${"createdAt" in event ? event.createdAt : event.created_at}::${event.id}`; -} - -function parseRecordWithWarning( - raw: string | null, - warning: Omit -): { value: Record | null; warning: MissionDetailWarning | null } { - if (typeof raw !== "string" || raw.trim().length === 0) { - return { value: null, warning: null }; - } - try { - const parsed = JSON.parse(raw); - if (isRecord(parsed)) { - return { value: parsed, warning: null }; - } - return { - value: null, - warning: { - ...warning, - code: "invalid_json", - message: "Stored JSON did not decode to an object.", - } - }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { - value: null, - warning: { - ...warning, - code: "invalid_json", - message: `Stored JSON could not be parsed: ${detail}`, - } - }; - } -} - -function coerceNumber(value: unknown): number | undefined { - if (value == null) return undefined; - const n = Number(value); - if (!Number.isFinite(n)) return undefined; - return n; -} - -function coerceBoolean(value: unknown, fallback = false): boolean { - if (typeof value === "boolean") return value; - return fallback; -} - -const VALID_EVIDENCE_REQUIREMENTS = new Set([ - "planning_document", - "research_summary", - "changed_files_summary", - "test_report", - "review_summary", - "risk_notes", - "final_outcome_summary", - "screenshot", - "browser_verification", - "video_recording", - "browser_trace", - "console_logs", -]); - -function toEvidenceRequirements(value: unknown): ValidationEvidenceRequirement[] | undefined { - if (!Array.isArray(value)) return undefined; - const requirements = value.filter((entry): entry is ValidationEvidenceRequirement => - typeof entry === "string" && VALID_EVIDENCE_REQUIREMENTS.has(entry as ValidationEvidenceRequirement) - ); - return requirements.length > 0 ? Array.from(new Set(requirements)) : undefined; -} - -function toCapabilityFallback(value: unknown): ValidationCapabilityFallbackPolicy | undefined { - return value === "block" || value === "warn" ? value : undefined; -} - -function normalizePlannerClarifyingQuestion(value: unknown): PlannerClarifyingQuestion | null { - if (!isRecord(value)) return null; - const question = String(value.question ?? "").trim(); - if (!question.length) return null; - const context = String(value.context ?? "").trim(); - const defaultAssumption = String(value.defaultAssumption ?? "").trim(); - const impact = String(value.impact ?? "").trim(); - return { - question, - ...(context.length ? { context: context.slice(0, 800) } : {}), - ...(defaultAssumption.length ? { defaultAssumption: defaultAssumption.slice(0, 800) } : {}), - ...(impact.length ? { impact: impact.slice(0, 800) } : {}) - }; -} - -function normalizePlannerClarifyingQuestions(value: unknown): PlannerClarifyingQuestion[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => normalizePlannerClarifyingQuestion(entry)) - .filter((entry): entry is PlannerClarifyingQuestion => entry != null) - .slice(0, 10); -} - -function normalizePlannerClarifyingAnswer(value: unknown): PlannerClarifyingAnswer | null { - if (!isRecord(value)) return null; - const question = String(value.question ?? "").trim(); - const answer = String(value.answer ?? "").trim(); - if (!question.length || !answer.length) return null; - const questionIndexRaw = Number(value.questionIndex); - const source = value.source === "default_assumption" ? "default_assumption" : "user"; - const answeredAtRaw = String(value.answeredAt ?? "").trim(); - const context = String(value.context ?? "").trim(); - const defaultAssumption = String(value.defaultAssumption ?? "").trim(); - const impact = String(value.impact ?? "").trim(); - return { - questionIndex: Number.isFinite(questionIndexRaw) ? Math.max(0, Math.floor(questionIndexRaw)) : 0, - question, - answer, - source, - answeredAt: answeredAtRaw.length ? answeredAtRaw : nowIso(), - ...(context.length ? { context: context.slice(0, 800) } : {}), - ...(defaultAssumption.length ? { defaultAssumption: defaultAssumption.slice(0, 800) } : {}), - ...(impact.length ? { impact: impact.slice(0, 800) } : {}) - }; -} - -function normalizePlannerClarifyingAnswers(value: unknown): PlannerClarifyingAnswer[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => normalizePlannerClarifyingAnswer(entry)) - .filter((entry): entry is PlannerClarifyingAnswer => entry != null) - .slice(0, 20); -} - -function isThinkingLevel(value: unknown): value is ThinkingLevel { - return value === "none" - || value === "minimal" - || value === "low" - || value === "medium" - || value === "high" - || value === "max" - || value === "xhigh"; -} - -function toModelConfig(value: unknown): PhaseCard["model"] { - const record = isRecord(value) ? value : {}; - const rawModelId = typeof record.modelId === "string" && record.modelId.trim().length > 0 - ? record.modelId.trim() - : typeof record.model === "string" && record.model.trim().length > 0 - ? record.model.trim() - : "anthropic/claude-sonnet-4-6"; - const descriptor = resolveModelDescriptor(rawModelId); - const modelId = descriptor?.id ?? rawModelId; - const providerHint = typeof record.provider === "string" && record.provider.trim().length > 0 - ? record.provider.trim().toLowerCase() - : descriptor?.family === "anthropic" - ? "claude" - : descriptor?.family === "openai" - ? "codex" - : null; - const thinkingLevel = isThinkingLevel(record.thinkingLevel) ? record.thinkingLevel : undefined; - return { - modelId, - ...(providerHint ? { provider: providerHint } : {}), - ...(thinkingLevel ? { thinkingLevel } : {}) - }; -} - -function toPhaseCard(value: unknown, fallbackPosition = 0): PhaseCard | null { - if (!isRecord(value)) return null; - const id = typeof value.id === "string" && value.id.trim().length > 0 ? value.id.trim() : randomUUID(); - const phaseKey = typeof value.phaseKey === "string" && value.phaseKey.trim().length > 0 - ? value.phaseKey.trim() - : ""; - const name = typeof value.name === "string" && value.name.trim().length > 0 ? value.name.trim() : phaseKey; - if (!phaseKey.length || !name.length) return null; - const description = typeof value.description === "string" ? value.description : ""; - const instructions = typeof value.instructions === "string" ? value.instructions : ""; - const position = Number.isFinite(Number(value.position)) ? Math.max(0, Math.floor(Number(value.position))) : fallbackPosition; - const budget = isRecord(value.budget) ? value.budget : {}; - const orderingConstraints = isRecord(value.orderingConstraints) ? value.orderingConstraints : {}; - const askQuestions = isRecord(value.askQuestions) ? value.askQuestions : {}; - const validationGate = isRecord(value.validationGate) ? value.validationGate : {}; - const createdAt = typeof value.createdAt === "string" ? value.createdAt : nowIso(); - const updatedAt = typeof value.updatedAt === "string" ? value.updatedAt : nowIso(); - const tier = validationGate.tier === "none" - || validationGate.tier === "self" - || validationGate.tier === "dedicated" - ? validationGate.tier - : "self"; - return { - id, - phaseKey, - name, - description, - instructions, - model: toModelConfig(value.model), - budget: { - maxTokens: coerceNumber(budget.maxTokens), - maxTimeMs: coerceNumber(budget.maxTimeMs), - maxSteps: coerceNumber(budget.maxSteps), - }, - orderingConstraints: { - mustBeFirst: coerceBoolean(orderingConstraints.mustBeFirst), - mustBeLast: coerceBoolean(orderingConstraints.mustBeLast), - mustFollow: Array.isArray(orderingConstraints.mustFollow) - ? orderingConstraints.mustFollow.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : [], - mustPrecede: Array.isArray(orderingConstraints.mustPrecede) - ? orderingConstraints.mustPrecede.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : [], - canLoop: coerceBoolean(orderingConstraints.canLoop), - loopTarget: typeof orderingConstraints.loopTarget === "string" && orderingConstraints.loopTarget.trim().length > 0 - ? orderingConstraints.loopTarget.trim() - : null - }, - askQuestions: { - enabled: coerceBoolean(askQuestions.enabled), - maxQuestions: coerceNumber(askQuestions.maxQuestions), - requiredBeforeExit: askQuestions.requiredBeforeExit === true ? true : undefined, - }, - validationGate: { - tier, - required: coerceBoolean(validationGate.required, tier !== "none"), - criteria: typeof validationGate.criteria === "string" ? validationGate.criteria : undefined, - evidenceRequirements: toEvidenceRequirements(validationGate.evidenceRequirements), - capabilityFallback: toCapabilityFallback(validationGate.capabilityFallback), - }, - requiresApproval: coerceBoolean(value.requiresApproval), - isBuiltIn: coerceBoolean(value.isBuiltIn), - isCustom: coerceBoolean(value.isCustom, true), - position, - createdAt, - updatedAt, - }; -} - -function toPhaseCards(raw: unknown[]): PhaseCard[] { - return raw - .map((entry, index) => toPhaseCard(entry, index)) - .filter((entry): entry is PhaseCard => entry != null) - .sort((a, b) => a.position - b.position) - .map((phase, index) => ({ ...phase, position: index })); -} - -function normalizePhaseCards(phases: PhaseCard[]): PhaseCard[] { - return phases - .map((phase, index) => { - const phaseKey = String(phase.phaseKey ?? "").trim().toLowerCase(); - const planningPhase = phaseKey === "planning"; - const testingOrValidation = phaseKey === "testing" || phaseKey === "validation"; - const customPhase = phase.isCustom === true || phase.isBuiltIn !== true; - const askQuestionsEnabled = planningPhase - ? phase.askQuestions.enabled !== false - : customPhase && phase.askQuestions.enabled === true; - const rawMaxQuestions = phase.askQuestions.maxQuestions; - const askQuestions: PhaseCard["askQuestions"] = { - ...phase.askQuestions, - enabled: askQuestionsEnabled, - maxQuestions: askQuestionsEnabled - ? rawMaxQuestions == null - ? null - : Math.max(1, Math.min(10, Number(rawMaxQuestions) || 5)) - : undefined, - requiredBeforeExit: askQuestionsEnabled && phase.askQuestions.requiredBeforeExit === true ? true : undefined, - }; - const validationGate: PhaseCard["validationGate"] = planningPhase || phaseKey === "development" - ? { - ...phase.validationGate, - tier: "none", - required: false, - criteria: undefined, - evidenceRequirements: undefined, - } - : testingOrValidation - ? { - ...phase.validationGate, - tier: phase.validationGate.tier === "none" ? "dedicated" : phase.validationGate.tier, - } - : phase.validationGate; - const requiresApproval = phase.requiresApproval === true; - return { - ...phase, - askQuestions, - validationGate, - requiresApproval, - position: index, - }; - }); -} - -function toPhaseProfile(row: PhaseProfileRow): PhaseProfile { - const phases = toPhaseCards(safeParseArray(row.phases_json)); - return { - id: row.id, - name: row.name, - description: row.description, - phases, - isBuiltIn: Number(row.is_built_in) === 1, - isDefault: Number(row.is_default) === 1, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -function toMissionPhaseOverride(row: MissionPhaseOverrideRow): MissionPhaseOverride { - return { - id: row.id, - missionId: row.mission_id, - profileId: row.profile_id, - phases: toPhaseCards(safeParseArray(row.phases_json)), - createdAt: row.created_at, - updatedAt: row.updated_at - }; -} - -function sanitizeFilePart(value: string): string { - const normalized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/-+/g, "-"); - const cleaned = normalized.replace(/^-+/, "").replace(/-+$/, ""); - return cleaned.length ? cleaned : "phase-profile"; -} - -export function normalizeMissionStatus(value: string): MissionStatus { - if (value === "plan_review") return "in_progress"; - if ( - value === "queued" || - value === "planning" || - value === "in_progress" || - value === "intervention_required" || - value === "completed" || - value === "failed" || - value === "canceled" - ) { - return value; - } - return "queued"; -} - -function normalizeMissionPriority(value: string): MissionPriority { - if (value === "urgent" || value === "high" || value === "normal" || value === "low") return value; - return "normal"; -} - -function normalizeExecutionMode(value: string): MissionExecutionMode { - if (value === "local" || value === "relay") return value; - return "local"; -} - -function normalizeStepStatus(value: string): MissionStepStatus { - if ( - value === "pending" || - value === "running" || - value === "succeeded" || - value === "failed" || - value === "skipped" || - value === "blocked" || - value === "canceled" - ) { - return value; - } - return "pending"; -} - -function normalizeArtifactType(value: string): MissionArtifactType { - return normalizeMissionArtifactTypeValue(value); -} - -function normalizeInterventionType(value: string): MissionInterventionType { - if ( - value === "approval_required" || - value === "manual_input" || - value === "conflict" || - value === "policy_block" || - value === "failed_step" || - value === "orchestrator_escalation" || - value === "budget_limit_reached" || - value === "provider_unreachable" || - value === "unrecoverable_error" || - value === "phase_approval" - ) { - return value; - } - return "manual_input"; -} - -function normalizeInterventionStatus(value: string): MissionInterventionStatus { - if (value === "open" || value === "resolved" || value === "dismissed") return value; - return "open"; -} - -function normalizePrompt(prompt: string): string { - return prompt - .replace(/\r\n/g, "\n") - .split("\n") - .map((line) => line.trim()) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -function summarizePrompt(prompt: string): string { - const oneLine = prompt.replace(/\s+/g, " ").trim(); - if (!oneLine.length) return "Mission"; - if (oneLine.length <= 88) return oneLine; - return `${oneLine.slice(0, 85)}...`; -} - -function deriveMissionTitle(prompt: string, explicit?: string): string { - const cleanedExplicit = (explicit ?? "").trim(); - if (cleanedExplicit.length) return cleanedExplicit.slice(0, 140); - const firstSentence = normalizePrompt(prompt).split(/(?<=[.!?])\s+/)[0] ?? ""; - const compact = firstSentence.trim() || summarizePrompt(prompt); - return compact.slice(0, 140); -} - -function sanitizeOptionalText(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length ? trimmed : null; -} - -function coerceNullableString(value: unknown): string | null { - if (value == null) return null; - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length ? trimmed : null; -} - -function toMissionSummary(row: MissionRow): MissionSummary { - return { - id: row.id, - title: row.title, - prompt: row.prompt, - laneId: row.lane_id, - laneName: row.lane_name, - missionLaneId: row.mission_lane_id ?? null, - missionLaneName: row.mission_lane_name ?? null, - resultLaneId: row.result_lane_id ?? null, - resultLaneName: row.result_lane_name ?? null, - status: normalizeMissionStatus(row.status), - priority: normalizeMissionPriority(row.priority), - executionMode: normalizeExecutionMode(row.execution_mode), - targetMachineId: row.target_machine_id, - outcomeSummary: row.outcome_summary, - lastError: row.last_error, - artifactCount: Number(row.artifact_count ?? 0), - openInterventions: Number(row.open_interventions ?? 0), - totalSteps: Number(row.total_steps ?? 0), - completedSteps: Number(row.completed_steps ?? 0), - createdAt: row.created_at, - updatedAt: row.updated_at, - startedAt: row.started_at, - completedAt: row.completed_at - }; -} - -function toMissionStep(row: MissionStepRow): MissionStep { - return { - id: row.id, - missionId: row.mission_id, - index: Number(row.step_index ?? 0), - title: row.title, - detail: row.detail, - kind: row.kind, - laneId: row.lane_id, - status: normalizeStepStatus(row.status), - createdAt: row.created_at, - updatedAt: row.updated_at, - startedAt: row.started_at, - completedAt: row.completed_at, - metadata: safeParseRecord(row.metadata_json) - }; -} - -function toMissionIntervention(row: MissionInterventionRow): MissionIntervention { - const resolutionKindRaw = typeof row.resolution_kind === "string" ? row.resolution_kind.trim() : ""; - const resolutionKind: MissionInterventionResolutionKind | null = - isValidResolutionKind(resolutionKindRaw) ? resolutionKindRaw : null; - return { - id: row.id, - missionId: row.mission_id, - interventionType: normalizeInterventionType(row.intervention_type), - status: normalizeInterventionStatus(row.status), - resolutionKind, - title: row.title, - body: row.body, - requestedAction: row.requested_action, - resolutionNote: row.resolution_note, - laneId: row.lane_id, - createdAt: row.created_at, - updatedAt: row.updated_at, - resolvedAt: row.resolved_at, - metadata: safeParseRecord(row.metadata_json) - }; -} - -function hasTransition( - graph: Record>, - from: MissionStatus, - to: MissionStatus -): boolean { - return graph[from]?.has(to) ?? false; -} - -export function isValidMissionTransition(from: MissionStatus, to: MissionStatus): boolean { - return hasTransition(MISSION_TRANSITIONS, from, to); -} - -export function isValidMissionStepTransition(from: MissionStepStatus, to: MissionStepStatus): boolean { - return STEP_TRANSITIONS[from]?.has(to) ?? false; -} - -export function createMissionService({ - db, - projectId, - projectRoot, - logger, - onEvent, - concurrencyConfig, - onInterventionResolved, - onBlockingInterventionAdded, -}: { - db: AdeDb; - projectId: string; - projectRoot?: string; - logger?: Logger; - onEvent?: (payload: MissionsEventPayload) => void; - concurrencyConfig?: Partial; - onInterventionResolved?: (args: { - missionId: string; - intervention: MissionIntervention; - }) => void | Promise; - onBlockingInterventionAdded?: (args: { - missionId: string; - intervention: MissionIntervention; - }) => void | Promise; -}) { - let activeConcurrencyConfig: MissionConcurrencyConfig = { - ...DEFAULT_CONCURRENCY_CONFIG, - ...concurrencyConfig - }; - - const ensureMissionSchemaCompatibility = () => { - const missionColumns = db.all<{ name: string }>("pragma table_info(missions)"); - const laneColumns = db.all<{ name: string }>("pragma table_info(lanes)"); - const missionColumnNames = new Set(missionColumns.map((column) => column.name)); - const laneColumnNames = new Set(laneColumns.map((column) => column.name)); - if (!missionColumnNames.has("archived_at")) db.run("alter table missions add column archived_at text"); - if (!missionColumnNames.has("mission_lane_id")) db.run("alter table missions add column mission_lane_id text"); - if (!missionColumnNames.has("result_lane_id")) db.run("alter table missions add column result_lane_id text"); - if (!missionColumnNames.has("queue_claim_token")) db.run("alter table missions add column queue_claim_token text"); - if (!missionColumnNames.has("queue_claimed_at")) db.run("alter table missions add column queue_claimed_at text"); - if (!laneColumnNames.has("mission_id")) db.run("alter table lanes add column mission_id text"); - if (!laneColumnNames.has("lane_role")) db.run("alter table lanes add column lane_role text"); - db.run("create index if not exists idx_missions_project_mission_lane on missions(project_id, mission_lane_id)"); - db.run("create index if not exists idx_missions_project_result_lane on missions(project_id, result_lane_id)"); - db.run("drop index if exists idx_missions_queue_claim_token"); - db.run("create index if not exists idx_missions_project_queue_claim on missions(project_id, queue_claim_token)"); - db.run("create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id)"); - db.run("create index if not exists idx_lanes_project_role on lanes(project_id, lane_role)"); - }; - - ensureMissionSchemaCompatibility(); - - // Note: ON DELETE CASCADE foreign key migrations are handled centrally by - // kvDb's retrofitForeignKeyCascadeActions() which runs at database open time, - // before any service is created. No per-service FK migration is needed here. - - // Late-bound reference to the service object for use in internal helpers. - // Assigned after the return object is created. Uses a minimal interface - // to avoid circular type dependency. - let serviceRef: { - processQueue(): string[]; - isLaneClaimed(laneId: string, excludeMissionId?: string): MissionLaneClaimCheckResult; - } | null = null; - - const emit = (payload: Omit) => { - try { - onEvent?.({ - type: "missions-updated", - at: nowIso(), - ...payload - }); - } catch { - // Ignore broadcast failures. - } - }; - - const assertLaneExists = (laneId: string | null | undefined) => { - if (!laneId) return; - const hit = db.get<{ id: string }>( - "select id from lanes where id = ? and project_id = ? and status != 'archived' limit 1", - [laneId, projectId] - ); - if (!hit?.id) { - throw new Error(`Lane not found or archived: ${laneId}`); - } - }; - - const getMissionLaunchLaneConflict = (laneId: string, excludeMissionId?: string): string | null => { - const lane = db.get<{ - id: string; - status: string; - archived_at: string | null; - mission_id: string | null; - lane_role: string | null; - rebase_in_progress: number | null; - }>( - ` - select - l.id, - l.status, - l.archived_at, - l.mission_id, - l.lane_role, - coalesce(s.rebase_in_progress, 0) as rebase_in_progress - from lanes l - left join lane_state_snapshots s on s.lane_id = l.id - where l.id = ? - and l.project_id = ? - limit 1 - `, - [laneId, projectId] - ); - if (!lane?.id) return `Lane not found: ${laneId}`; - if (lane.archived_at || lane.status === "archived") return "Lane is archived."; - if (Number(lane.rebase_in_progress ?? 0) === 1) return "Lane is currently rebasing."; - if (lane.mission_id && lane.mission_id !== excludeMissionId) { - const owner = db.get<{ id: string }>( - ` - select id - from missions - where id = ? - and project_id = ? - limit 1 - `, - [lane.mission_id, projectId] - ); - if (owner?.id) { - return "Lane is already owned by another mission."; - } - db.run( - ` - update lanes - set mission_id = null, - lane_role = null - where id = ? - and project_id = ? - and mission_id = ? - `, - [laneId, projectId, lane.mission_id] - ); - logger?.info("missions.lane_ghost_claim_cleared", { - projectId, - laneId, - staleMissionId: lane.mission_id, - }); - db.flushNow(); - } - const existingResultMission = db.get<{ id: string }>( - ` - select id - from missions - where project_id = ? - and result_lane_id = ? - and archived_at is null - and (? is null or id != ?) - limit 1 - `, - [projectId, laneId, excludeMissionId ?? null, excludeMissionId ?? null] - ); - if (existingResultMission?.id) { - return "Lane is already assigned as another mission's result lane."; - } - return null; - }; - - const assertMissionLaunchLaneAvailable = (laneId: string | null | undefined, excludeMissionId?: string) => { - if (!laneId) return; - const conflict = getMissionLaunchLaneConflict(laneId, excludeMissionId); - if (conflict) { - throw new Error(conflict); - } - }; - - const claimQueuedMissionStart = (missionId: string): string | null => { - const staleBefore = new Date(Date.now() - MISSION_QUEUED_CLAIM_STALE_MS).toISOString(); - db.run("begin immediate"); - try { - const row = db.get<{ - status: string; - lane_id: string | null; - queue_claim_token: string | null; - queue_claimed_at: string | null; - metadata_json: string | null; - }>( - ` - select status, lane_id, queue_claim_token, queue_claimed_at, metadata_json - from missions - where id = ? - and project_id = ? - and archived_at is null - limit 1 - `, - [missionId, projectId] - ); - if (!row || normalizeMissionStatus(row.status) !== "queued") { - db.run("commit"); - return null; - } - const launchMetadata = parseRecordWithWarning(row.metadata_json, { - source: "mission", - field: "metadata_json", - recordId: missionId, - }).value; - const launch = launchMetadata && isRecord(launchMetadata.launch) ? launchMetadata.launch : null; - if (launch && launch.autostart === false) { - db.run("commit"); - return null; - } - const existingClaimIsFresh = - typeof row.queue_claim_token === "string" - && row.queue_claim_token.trim().length > 0 - && typeof row.queue_claimed_at === "string" - && row.queue_claimed_at >= staleBefore; - if (existingClaimIsFresh) { - db.run("commit"); - return null; - } - if (activeConcurrencyConfig.laneExclusivity && row.lane_id) { - const laneConflict = getMissionLaunchLaneConflict(row.lane_id, missionId); - if (laneConflict || serviceRef?.isLaneClaimed(row.lane_id, missionId).claimed) { - db.run("commit"); - return null; - } - } - const token = randomUUID(); - const claimedAt = nowIso(); - db.run( - ` - update missions - set queue_claim_token = ?, - queue_claimed_at = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [token, claimedAt, claimedAt, missionId, projectId] - ); - db.run("commit"); - return token; - } catch (error) { - try { - db.run("rollback"); - } catch { - // Ignore rollback failures after failed queue claim attempts. - } - throw error; - } - }; - - const clearMissionQueueClaim = (missionId: string, claimToken?: string | null): void => { - const normalizedToken = typeof claimToken === "string" ? claimToken.trim() : ""; - const params: Array = [nowIso(), missionId, projectId]; - let tokenClause = ""; - if (normalizedToken.length) { - tokenClause = "and queue_claim_token = ?"; - params.push(normalizedToken); - } - db.run( - ` - update missions - set queue_claim_token = null, - queue_claimed_at = null, - updated_at = ? - where id = ? - and project_id = ? - ${tokenClause} - `, - params - ); - }; - - const beginMissionStart = (missionId: string, claimToken?: string | null): boolean => { - const startedAt = nowIso(); - const normalizedToken = typeof claimToken === "string" ? claimToken.trim() : ""; - if (normalizedToken.length && normalizedToken !== MISSION_START_MANUAL_TOKEN) { - db.run( - ` - update missions - set status = 'planning', - queue_claim_token = null, - queue_claimed_at = null, - started_at = coalesce(started_at, ?), - updated_at = ? - where id = ? - and project_id = ? - and status = 'queued' - and archived_at is null - and queue_claim_token = ? - `, - [startedAt, startedAt, missionId, projectId, normalizedToken] - ); - } else { - db.run( - ` - update missions - set status = 'planning', - started_at = coalesce(started_at, ?), - updated_at = ? - where id = ? - and project_id = ? - and status = 'queued' - and archived_at is null - and queue_claim_token is null - `, - [startedAt, startedAt, missionId, projectId] - ); - } - const row = db.get<{ status: string; queue_claim_token: string | null }>( - ` - select status, queue_claim_token - from missions - where id = ? - and project_id = ? - limit 1 - `, - [missionId, projectId] - ); - return normalizeMissionStatus(row?.status ?? "") === "planning" && row?.queue_claim_token == null; - }; - - const baseMissionSelect = ` - select - m.id as id, - m.title as title, - m.prompt as prompt, - m.lane_id as lane_id, - launch_lane.name as lane_name, - m.mission_lane_id as mission_lane_id, - mission_lane.name as mission_lane_name, - m.result_lane_id as result_lane_id, - result_lane.name as result_lane_name, - m.status as status, - m.priority as priority, - m.execution_mode as execution_mode, - m.target_machine_id as target_machine_id, - m.outcome_summary as outcome_summary, - m.last_error as last_error, - m.metadata_json as metadata_json, - ( - select count(*) - from mission_artifacts ma - where ma.project_id = m.project_id and ma.mission_id = m.id - ) as artifact_count, - ( - select count(*) - from mission_interventions mi - where mi.project_id = m.project_id and mi.mission_id = m.id and mi.status = 'open' - ) as open_interventions, - ( - select count(*) - from mission_steps ms - where ms.project_id = m.project_id and ms.mission_id = m.id - ) as total_steps, - ( - select count(*) - from mission_steps ms - where ms.project_id = m.project_id and ms.mission_id = m.id and ms.status in ('succeeded', 'skipped') - ) as completed_steps, - m.created_at as created_at, - m.updated_at as updated_at, - m.started_at as started_at, - m.completed_at as completed_at, - m.archived_at as archived_at, - m.queue_claim_token as queue_claim_token, - m.queue_claimed_at as queue_claimed_at - from missions m - left join lanes launch_lane on launch_lane.id = m.lane_id - left join lanes mission_lane on mission_lane.id = m.mission_lane_id - left join lanes result_lane on result_lane.id = m.result_lane_id - where m.project_id = ? - `; - - const getMissionRow = (missionId: string): MissionRow | null => { - return db.get( - `${baseMissionSelect} - and m.id = ? - and m.archived_at is null - limit 1`, - [projectId, missionId] - ); - }; - - const recordEvent = (args: { - missionId: string; - eventType: string; - actor: string; - summary: string; - payload?: Record | null; - }): MissionEvent => { - const id = randomUUID(); - const createdAt = nowIso(); - db.run( - ` - insert into mission_events( - id, - mission_id, - project_id, - event_type, - actor, - summary, - payload_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - args.missionId, - projectId, - args.eventType, - args.actor, - args.summary, - args.payload ? JSON.stringify(args.payload) : null, - createdAt - ] - ); - return { - id, - missionId: args.missionId, - eventType: args.eventType, - actor: args.actor, - summary: args.summary, - payload: args.payload ?? null, - createdAt - }; - }; - - const upsertMissionStatus = (args: { - missionId: string; - nextStatus: MissionStatus; - updatedAt?: string; - summary?: string; - payload?: Record; - actor?: string; - }) => { - const row = db.get<{ - status: string; - started_at: string | null; - completed_at: string | null; - }>( - "select status, started_at, completed_at from missions where id = ? and project_id = ? limit 1", - [args.missionId, projectId] - ); - if (!row) throw new Error(`Mission not found: ${args.missionId}`); - - const previous = normalizeMissionStatus(row.status); - const next = args.nextStatus; - if (!isValidMissionTransition(previous, next)) { - throw new Error(`Invalid mission transition: ${previous} -> ${next}`); - } - - const updatedAt = args.updatedAt ?? nowIso(); - let startedAt = row.started_at; - let completedAt = row.completed_at; - - if (next === "planning" || next === "in_progress") { - if (!startedAt) startedAt = updatedAt; - completedAt = null; - } else if (next === "queued") { - startedAt = null; - completedAt = null; - } else if (TERMINAL_MISSION_STATUSES.has(next)) { - completedAt = updatedAt; - if (!startedAt) startedAt = updatedAt; - } - - db.run( - ` - update missions - set status = ?, - started_at = ?, - completed_at = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [next, startedAt, completedAt, updatedAt, args.missionId, projectId] - ); - - // Strip orchestrator chat cache from metadata when mission reaches a terminal - // state — it's redundant with orchestrator_chat_messages and can be large. - if (TERMINAL_MISSION_STATUSES.has(next)) { - try { - db.run( - ` - update missions - set metadata_json = json_remove( - json_remove(metadata_json, '$.orchestratorChat'), - '$.orchestratorChatSession' - ) - where id = ? - and project_id = ? - and metadata_json is not null - `, - [args.missionId, projectId] - ); - } catch { - // Non-critical — don't let cleanup failures break the status transition. - } - } - - - if (previous !== next) { - recordEvent({ - missionId: args.missionId, - eventType: "mission_status_changed", - actor: args.actor ?? "user", - summary: args.summary ?? `Mission status changed to ${next}.`, - payload: { - from: previous, - to: next, - ...(args.payload ?? {}) - } - }); - - // When a mission reaches a terminal status, process the queue to - // start the next eligible queued mission. - if (TERMINAL_MISSION_STATUSES.has(next) && serviceRef) { - try { - serviceRef.processQueue(); - } catch { - // Ignore queue processing failures — they should not break - // the status transition that already succeeded. - } - } - } - }; - - const insertArtifact = (args: { - missionId: string; - artifactType: MissionArtifactType; - title: string; - description?: string | null; - uri?: string | null; - laneId?: string | null; - createdBy: string; - metadata?: Record | null; - }): MissionArtifact => { - assertLaneExists(args.laneId ?? null); - - const id = randomUUID(); - const createdAt = nowIso(); - const title = args.title.trim(); - if (!title.length) throw new Error("Artifact title is required"); - - const description = sanitizeOptionalText(args.description ?? null); - const uri = coerceNullableString(args.uri); - - db.run( - ` - insert into mission_artifacts( - id, - mission_id, - project_id, - artifact_type, - title, - description, - uri, - lane_id, - metadata_json, - created_at, - updated_at, - created_by - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - args.missionId, - projectId, - args.artifactType, - title, - description, - uri, - args.laneId ?? null, - args.metadata ? JSON.stringify(args.metadata) : null, - createdAt, - createdAt, - args.createdBy - ] - ); - - return { - id, - missionId: args.missionId, - artifactType: args.artifactType, - title, - description, - uri, - laneId: args.laneId ?? null, - createdBy: args.createdBy, - createdAt, - updatedAt: createdAt, - metadata: args.metadata ?? null - }; - }; - - const insertIntervention = (args: { - missionId: string; - interventionType: MissionInterventionType; - title: string; - body: string; - requestedAction?: string | null; - laneId?: string | null; - metadata?: Record | null; - }): MissionIntervention => { - assertLaneExists(args.laneId ?? null); - - const id = randomUUID(); - const createdAt = nowIso(); - const title = args.title.trim(); - const body = args.body.trim(); - if (!title.length) throw new Error("Intervention title is required"); - if (!body.length) throw new Error("Intervention body is required"); - - db.run( - ` - insert into mission_interventions( - id, - mission_id, - project_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - metadata_json, - created_at, - updated_at, - resolved_at - ) values (?, ?, ?, ?, 'open', null, ?, ?, ?, null, ?, ?, ?, ?, null) - `, - [ - id, - args.missionId, - projectId, - args.interventionType, - title, - body, - sanitizeOptionalText(args.requestedAction ?? null), - args.laneId ?? null, - args.metadata ? JSON.stringify(args.metadata) : null, - createdAt, - createdAt - ] - ); - - return { - id, - missionId: args.missionId, - interventionType: args.interventionType, - status: "open", - resolutionKind: null, - title, - body, - requestedAction: sanitizeOptionalText(args.requestedAction ?? null), - resolutionNote: null, - laneId: args.laneId ?? null, - createdAt, - updatedAt: createdAt, - resolvedAt: null, - metadata: args.metadata ?? null - }; - }; - - - const profilesDir = projectRoot ? path.join(projectRoot, ".ade", "profiles") : null; - let phaseStorageSeeded = false; - - const listPhaseProfileRows = (includeArchived = false): PhaseProfileRow[] => - db.all( - ` - select - id, - project_id, - name, - description, - phases_json, - is_built_in, - is_default, - created_at, - updated_at, - archived_at - from phase_profiles - where project_id = ? - ${includeArchived ? "" : "and archived_at is null"} - order by is_default desc, is_built_in desc, updated_at desc, name asc - `, - [projectId] - ); - - const getPhaseProfileRow = (profileId: string): PhaseProfileRow | null => - db.get( - ` - select - id, - project_id, - name, - description, - phases_json, - is_built_in, - is_default, - created_at, - updated_at, - archived_at - from phase_profiles - where project_id = ? - and id = ? - limit 1 - `, - [projectId, profileId] - ); - - const getDefaultPhaseProfileRow = (): PhaseProfileRow | null => - db.get( - ` - select - id, - project_id, - name, - description, - phases_json, - is_built_in, - is_default, - created_at, - updated_at, - archived_at - from phase_profiles - where project_id = ? - and archived_at is null - and is_default = 1 - order by updated_at desc - limit 1 - `, - [projectId] - ); - - const listPhaseCardRows = (includeArchived = false): PhaseCardRow[] => - db.all( - ` - select - id, - project_id, - phase_key, - name, - description, - instructions, - model_json, - budget_json, - ordering_constraints_json, - ask_questions_json, - validation_gate_json, - is_built_in, - is_custom, - position, - archived_at, - created_at, - updated_at - from phase_cards - where project_id = ? - ${includeArchived ? "" : "and archived_at is null"} - order by position asc, updated_at desc, name asc - `, - [projectId] - ); - - const getPhaseCardRowByKey = (phaseKey: string): PhaseCardRow | null => - db.get( - ` - select - id, - project_id, - phase_key, - name, - description, - instructions, - model_json, - budget_json, - ordering_constraints_json, - ask_questions_json, - validation_gate_json, - is_built_in, - is_custom, - position, - archived_at, - created_at, - updated_at - from phase_cards - where project_id = ? - and phase_key = ? - order by updated_at desc - limit 1 - `, - [projectId, phaseKey] - ); - - const toPhaseCardFromRow = (row: PhaseCardRow): PhaseCard => { - const parsed = toPhaseCard( - { - id: row.id, - phaseKey: row.phase_key, - name: row.name, - description: row.description, - instructions: row.instructions, - model: safeJsonParse(row.model_json, null), - budget: safeJsonParse(row.budget_json, null), - orderingConstraints: safeJsonParse(row.ordering_constraints_json, null), - askQuestions: safeJsonParse(row.ask_questions_json, null), - validationGate: safeJsonParse(row.validation_gate_json, null), - isBuiltIn: Number(row.is_built_in) === 1, - isCustom: Number(row.is_custom) === 1, - position: row.position, - createdAt: row.created_at, - updatedAt: row.updated_at, - }, - row.position - ); - if (!parsed) { - throw new Error(`Invalid phase card row for key: ${row.phase_key}`); - } - return parsed; - }; - - const upsertPhaseCardRow = (card: PhaseCard) => { - const existing = db.get<{ id: string }>( - ` - select id - from phase_cards - where project_id = ? - and phase_key = ? - limit 1 - `, - [projectId, card.phaseKey] - ); - if (existing?.id) { - db.run( - ` - update phase_cards - set id = ?, - name = ?, - description = ?, - instructions = ?, - model_json = ?, - budget_json = ?, - ordering_constraints_json = ?, - ask_questions_json = ?, - validation_gate_json = ?, - is_built_in = ?, - is_custom = ?, - position = ?, - archived_at = null, - updated_at = ? - where project_id = ? - and phase_key = ? - `, - [ - card.id, - card.name, - card.description, - card.instructions, - JSON.stringify(card.model), - JSON.stringify(card.budget ?? {}), - JSON.stringify(card.orderingConstraints ?? {}), - JSON.stringify(card.askQuestions ?? {}), - JSON.stringify(card.validationGate ?? {}), - card.isBuiltIn ? 1 : 0, - card.isCustom ? 1 : 0, - card.position, - nowIso(), - projectId, - card.phaseKey - ] - ); - return; - } - db.run( - ` - insert into phase_cards( - id, - project_id, - phase_key, - name, - description, - instructions, - model_json, - budget_json, - ordering_constraints_json, - ask_questions_json, - validation_gate_json, - is_built_in, - is_custom, - position, - archived_at, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, ?, ?) - `, - [ - card.id, - projectId, - card.phaseKey, - card.name, - card.description, - card.instructions, - JSON.stringify(card.model), - JSON.stringify(card.budget ?? {}), - JSON.stringify(card.orderingConstraints ?? {}), - JSON.stringify(card.askQuestions ?? {}), - JSON.stringify(card.validationGate ?? {}), - card.isBuiltIn ? 1 : 0, - card.isCustom ? 1 : 0, - card.position, - card.createdAt, - card.updatedAt - ] - ); - }; - - const upsertBuiltInPhaseProfileRow = (profile: PhaseProfile, now: string) => { - const existing = db.get<{ id: string }>( - ` - select id - from phase_profiles - where project_id = ? - and id = ? - limit 1 - `, - [projectId, profile.id] - ); - const normalizedPhases = normalizePhaseCards(profile.phases); - if (existing?.id) { - db.run( - ` - update phase_profiles - set name = ?, - description = ?, - phases_json = ?, - is_built_in = 1, - is_default = ?, - archived_at = null, - updated_at = ? - where project_id = ? - and id = ? - `, - [ - profile.name, - profile.description, - JSON.stringify(normalizedPhases), - profile.isDefault ? 1 : 0, - now, - projectId, - profile.id - ] - ); - return; - } - - db.run( - ` - insert into phase_profiles( - id, - project_id, - name, - description, - phases_json, - is_built_in, - is_default, - archived_at, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, 1, ?, null, ?, ?) - `, - [ - profile.id, - projectId, - profile.name, - profile.description, - JSON.stringify(normalizedPhases), - profile.isDefault ? 1 : 0, - profile.createdAt, - now - ] - ); - }; - - const ensurePhaseStorageSeeded = () => { - if (phaseStorageSeeded) return; - - const now = nowIso(); - const canonicalBuiltInCards = createBuiltInPhaseCards(now); - const canonicalBuiltInProfiles = createBuiltInPhaseProfiles(canonicalBuiltInCards, now); - const canonicalCardKeys = new Set(canonicalBuiltInCards.map((card) => card.phaseKey)); - const canonicalProfileIds = new Set(canonicalBuiltInProfiles.map((profile) => profile.id)); - - // Always upsert current built-ins so legacy/stale rows are corrected. - for (const card of canonicalBuiltInCards) { - upsertPhaseCardRow({ - ...card, - isBuiltIn: true, - isCustom: false, - updatedAt: now - }); - } - - for (const profile of canonicalBuiltInProfiles) { - upsertBuiltInPhaseProfileRow(profile, now); - } - - // Archive legacy built-in cards that are no longer part of canonical defaults. - const activeBuiltInCards = db.all<{ phase_key: string }>( - ` - select phase_key - from phase_cards - where project_id = ? - and is_built_in = 1 - and archived_at is null - `, - [projectId] - ); - const legacyBuiltInCardKeys = activeBuiltInCards - .map((row) => row.phase_key) - .filter((phaseKey) => !canonicalCardKeys.has(phaseKey)); - if (legacyBuiltInCardKeys.length > 0) { - const placeholders = legacyBuiltInCardKeys.map(() => "?").join(", "); - db.run( - ` - update phase_cards - set archived_at = ?, - updated_at = ? - where project_id = ? - and is_built_in = 1 - and archived_at is null - and phase_key in (${placeholders}) - `, - [now, now, projectId, ...legacyBuiltInCardKeys] - ); - } - - // Archive legacy built-in profiles that are no longer canonical. - const activeBuiltInProfiles = db.all<{ id: string }>( - ` - select id - from phase_profiles - where project_id = ? - and is_built_in = 1 - and archived_at is null - `, - [projectId] - ); - const legacyBuiltInProfileIds = activeBuiltInProfiles - .map((row) => row.id) - .filter((id) => !canonicalProfileIds.has(id)); - if (legacyBuiltInProfileIds.length > 0) { - const placeholders = legacyBuiltInProfileIds.map(() => "?").join(", "); - db.run( - ` - update phase_profiles - set archived_at = ?, - is_default = 0, - updated_at = ? - where project_id = ? - and is_built_in = 1 - and archived_at is null - and id in (${placeholders}) - `, - [now, now, projectId, ...legacyBuiltInProfileIds] - ); - } - - const existingDefault = getDefaultPhaseProfileRow(); - if (!existingDefault) { - const builtInDefault = db.get<{ id: string }>( - ` - select id - from phase_profiles - where project_id = ? - and archived_at is null - order by - case when id = 'builtin:default' then 0 else 1 end, - is_built_in desc, - created_at asc - limit 1 - `, - [projectId] - ); - if (builtInDefault?.id) { - db.run("update phase_profiles set is_default = 0 where project_id = ?", [projectId]); - db.run( - "update phase_profiles set is_default = 1, updated_at = ? where project_id = ? and id = ?", - [nowIso(), projectId, builtInDefault.id] - ); - } - } - - phaseStorageSeeded = true; - }; - - const getMissionPhaseOverrideRow = (missionId: string): MissionPhaseOverrideRow | null => - db.get( - ` - select - id, - mission_id, - profile_id, - phases_json, - created_at, - updated_at - from mission_phase_overrides - where project_id = ? - and mission_id = ? - limit 1 - `, - [projectId, missionId] - ); - - const upsertMissionPhaseOverride = (args: { - missionId: string; - profileId: string | null; - phases: PhaseCard[]; - }): MissionPhaseOverride => { - const existing = getMissionPhaseOverrideRow(args.missionId); - const now = nowIso(); - const phases = normalizePhaseCards(args.phases); - if (existing) { - db.run( - ` - update mission_phase_overrides - set profile_id = ?, - phases_json = ?, - updated_at = ? - where project_id = ? - and mission_id = ? - `, - [args.profileId, JSON.stringify(phases), now, projectId, args.missionId] - ); - const next = getMissionPhaseOverrideRow(args.missionId); - if (!next) throw new Error("Failed to update mission phase override."); - return toMissionPhaseOverride(next); - } - - const id = randomUUID(); - db.run( - ` - insert into mission_phase_overrides( - id, - mission_id, - project_id, - profile_id, - phases_json, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?) - `, - [id, args.missionId, projectId, args.profileId, JSON.stringify(phases), now, now] - ); - const created = getMissionPhaseOverrideRow(args.missionId); - if (!created) { - throw new Error("Failed to create mission phase override."); - } - return toMissionPhaseOverride(created); - }; - - const resolveMissionPhaseConfiguration = (missionId: string): MissionPhaseConfiguration | null => { - const mission = getMissionRow(missionId); - if (!mission) return null; - ensurePhaseStorageSeeded(); - const overrideRow = getMissionPhaseOverrideRow(missionId); - const override = overrideRow ? toMissionPhaseOverride(overrideRow) : null; - const selectedProfileRow = override?.profileId ? getPhaseProfileRow(override.profileId) : null; - const defaultProfileRow = selectedProfileRow ? null : getDefaultPhaseProfileRow(); - const profile = selectedProfileRow - ? toPhaseProfile(selectedProfileRow) - : defaultProfileRow - ? toPhaseProfile(defaultProfileRow) - : null; - const selectedPhases = normalizePhaseCards( - override?.phases?.length - ? override.phases - : profile?.phases?.length - ? profile.phases - : createBuiltInPhaseCards(nowIso()) - ); - return { - profile, - override, - selectedPhases - }; - }; - - const ensureUniqueProfileName = (name: string, excludeProfileId?: string): string => { - const base = name.trim() || "Custom Profile"; - const existingNames = new Set( - listPhaseProfileRows(true) - .filter((row) => row.id !== excludeProfileId) - .map((row) => row.name.trim().toLowerCase()) - ); - if (!existingNames.has(base.toLowerCase())) return base; - let idx = 2; - while (existingNames.has(`${base} (${idx})`.toLowerCase())) { - idx += 1; - } - return `${base} (${idx})`; - }; - - const estimateMissionCostUsd = (missionId: string): number | null => { - const rows = db.all<{ metadata_json: string | null; result_envelope_json: string | null }>( - ` - select metadata_json, result_envelope_json - from orchestrator_attempts - where project_id = ? - and run_id in ( - select id - from orchestrator_runs - where project_id = ? - and mission_id = ? - ) - `, - [projectId, projectId, missionId] - ); - let total = 0; - let seenAny = false; - for (const row of rows) { - const metadata = safeParseRecord(row.metadata_json); - const envelope = safeParseRecord(row.result_envelope_json); - const candidateValues = [ - metadata?.costUsd, - metadata?.estimatedCostUsd, - isRecord(metadata?.usage) ? (metadata.usage as Record).costUsd : undefined, - envelope?.costUsd, - envelope?.estimatedCostUsd, - isRecord(envelope?.usage) ? (envelope.usage as Record).costUsd : undefined - ]; - for (const candidate of candidateValues) { - const numeric = coerceNumber(candidate); - if (numeric == null) continue; - total += Math.max(0, numeric); - seenAny = true; - break; - } - } - return seenAny ? Number(total.toFixed(4)) : null; - }; - - const mapMissionQuickAction = (status: MissionStatus): "view" | "rerun" | "retry" => { - if (status === "completed") return "rerun"; - if (status === "failed") return "retry"; - return "view"; - }; - - const service = { - list(args: ListMissionsArgs = {}): MissionSummary[] { - const where: string[] = []; - const params: Array = [projectId]; - if (args.includeArchived !== true) { - where.push("m.archived_at is null"); - } - - const laneId = typeof args.laneId === "string" ? args.laneId.trim() : ""; - if (laneId.length) { - where.push("m.lane_id = ?"); - params.push(laneId); - } - - if (args.status === "active") { - where.push("m.status in ('queued', 'planning', 'plan_review', 'in_progress', 'intervention_required')"); - } else if (args.status === "in_progress") { - where.push("m.status in ('in_progress', 'plan_review')"); - } else if (args.status) { - where.push("m.status = ?"); - params.push(args.status); - } - - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 120))) : 120; - - const rows = db.all( - `${baseMissionSelect} - ${where.length ? `and ${where.join(" and ")}` : ""} - order by - case m.status - when 'intervention_required' then 0 - when 'in_progress' then 1 - when 'plan_review' then 1 - when 'planning' then 2 - when 'queued' then 3 - when 'failed' then 4 - when 'completed' then 5 - else 7 - end, - m.updated_at desc, - m.created_at desc - limit ?`, - [...params, limit] - ); - - return rows.map(toMissionSummary); - }, - - listEvents(args: GetMissionEventsArgs): MissionEventsPage { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId.length) { - throw new Error("missionId is required."); - } - if (!getMissionRow(missionId)) { - throw new Error(`Mission not found: ${missionId}`); - } - const requestedLimit = Number.isFinite(args.limit) - ? Math.max(1, Math.min(500, Math.floor(args.limit ?? MISSION_EVENTS_PAGE_LIMIT))) - : MISSION_EVENTS_PAGE_LIMIT; - const fetchLimit = requestedLimit + 1; - const before = parseMissionCursor(args.before ?? null); - const params: Array = [projectId, missionId]; - let beforeClause = ""; - if (before) { - beforeClause = ` - and ( - created_at < ? - or (created_at = ? and id < ?) - ) - `; - params.push(before.createdAt, before.createdAt, before.id); - } - params.push(fetchLimit); - const rows = db.all( - ` - select - id, - mission_id, - event_type, - actor, - summary, - payload_json, - created_at - from mission_events - where project_id = ? - and mission_id = ? - ${beforeClause} - order by created_at desc, id desc - limit ? - `, - params - ); - const hasMore = rows.length > requestedLimit; - const pageRows = hasMore ? rows.slice(0, requestedLimit) : rows; - const warnings: MissionDetailWarning[] = []; - const events = pageRows.map((row) => { - const parsedPayload = parseRecordWithWarning(row.payload_json, { - source: "event", - field: "payload_json", - recordId: row.id, - }); - if (parsedPayload.warning) warnings.push(parsedPayload.warning); - return { - id: row.id, - missionId: row.mission_id, - eventType: row.event_type, - actor: row.actor, - summary: row.summary, - payload: parsedPayload.value, - createdAt: row.created_at, - } satisfies MissionEvent; - }); - return { - missionId, - events, - nextCursor: hasMore && pageRows.length > 0 ? encodeMissionCursor(pageRows[pageRows.length - 1]) : null, - hasMore, - warnings, - }; - }, - - get(missionId: string): MissionDetail | null { - const id = missionId.trim(); - if (!id.length) return null; - - const row = getMissionRow(id); - if (!row) return null; - const warnings: MissionDetailWarning[] = []; - - const steps = db - .all( - ` - select - id, - mission_id, - step_index, - title, - detail, - kind, - lane_id, - status, - created_at, - updated_at, - started_at, - completed_at, - metadata_json - from mission_steps - where project_id = ? - and mission_id = ? - order by step_index asc - `, - [projectId, id] - ) - .map((stepRow) => { - const metadata = parseRecordWithWarning(stepRow.metadata_json, { - source: "step", - field: "metadata_json", - recordId: stepRow.id, - }); - if (metadata.warning) warnings.push(metadata.warning); - return { - id: stepRow.id, - missionId: stepRow.mission_id, - index: Number(stepRow.step_index ?? 0), - title: stepRow.title, - detail: stepRow.detail, - kind: stepRow.kind, - laneId: stepRow.lane_id, - status: normalizeStepStatus(stepRow.status), - createdAt: stepRow.created_at, - updatedAt: stepRow.updated_at, - startedAt: stepRow.started_at, - completedAt: stepRow.completed_at, - metadata: metadata.value, - } satisfies MissionStep; - }); - - const eventPage = this.listEvents({ missionId: id, limit: MISSION_EVENTS_PAGE_LIMIT }); - warnings.push(...eventPage.warnings); - if (eventPage.hasMore) { - warnings.push({ - code: "truncated_events", - source: "event", - field: "events", - recordId: id, - message: `Showing the newest ${MISSION_EVENTS_PAGE_LIMIT} mission events. Load more to inspect older history.`, - }); - } - - const artifacts = db - .all( - ` - select - id, - mission_id, - artifact_type, - title, - description, - uri, - lane_id, - created_by, - created_at, - updated_at, - metadata_json - from mission_artifacts - where project_id = ? - and mission_id = ? - order by created_at desc - `, - [projectId, id] - ) - .map((artifactRow) => { - const metadata = parseRecordWithWarning(artifactRow.metadata_json, { - source: "artifact", - field: "metadata_json", - recordId: artifactRow.id, - }); - if (metadata.warning) warnings.push(metadata.warning); - return { - id: artifactRow.id, - missionId: artifactRow.mission_id, - artifactType: normalizeArtifactType(artifactRow.artifact_type), - title: artifactRow.title, - description: artifactRow.description, - uri: artifactRow.uri, - laneId: artifactRow.lane_id, - createdBy: artifactRow.created_by, - createdAt: artifactRow.created_at, - updatedAt: artifactRow.updated_at, - metadata: metadata.value, - } satisfies MissionArtifact; - }); - - const interventions = db - .all( - ` - select - id, - mission_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - created_at, - updated_at, - resolved_at, - metadata_json - from mission_interventions - where project_id = ? - and mission_id = ? - order by - case status when 'open' then 0 when 'resolved' then 1 else 2 end, - created_at desc - `, - [projectId, id] - ) - .map((interventionRow) => { - const metadata = parseRecordWithWarning(interventionRow.metadata_json, { - source: "intervention", - field: "metadata_json", - recordId: interventionRow.id, - }); - if (metadata.warning) warnings.push(metadata.warning); - const resolutionKindRaw = typeof interventionRow.resolution_kind === "string" - ? interventionRow.resolution_kind.trim() - : ""; - return { - id: interventionRow.id, - missionId: interventionRow.mission_id, - interventionType: normalizeInterventionType(interventionRow.intervention_type), - status: normalizeInterventionStatus(interventionRow.status), - resolutionKind: isValidResolutionKind(resolutionKindRaw) ? resolutionKindRaw : null, - title: interventionRow.title, - body: interventionRow.body, - requestedAction: interventionRow.requested_action, - resolutionNote: interventionRow.resolution_note, - laneId: interventionRow.lane_id, - createdAt: interventionRow.created_at, - updatedAt: interventionRow.updated_at, - resolvedAt: interventionRow.resolved_at, - metadata: metadata.value, - } satisfies MissionIntervention; - }); - - const metadata = parseRecordWithWarning( - db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? and project_id = ? limit 1`, - [id, projectId] - )?.metadata_json ?? null, - { - source: "mission", - field: "metadata_json", - recordId: id, - } - ); - if (metadata.warning) warnings.push(metadata.warning); - const plannerPlan = isRecord(metadata.value?.plannerPlan) - ? (metadata.value.plannerPlan as PlannerPlan) - : null; - - return { - ...toMissionSummary(row), - steps, - events: eventPage.events, - artifacts, - interventions, - warnings, - plannerPlan, - phaseConfiguration: resolveMissionPhaseConfiguration(id), - }; - }, - - getMetadata(missionId: string): Record | null { - const id = missionId.trim(); - if (!id.length) return null; - - const row = db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? and project_id = ? limit 1`, - [id, projectId] - ); - return safeParseRecord(row?.metadata_json ?? null); - }, - - listPhaseItems(args: ListPhaseItemsArgs = {}): PhaseCard[] { - ensurePhaseStorageSeeded(); - return listPhaseCardRows(args.includeArchived === true).map(toPhaseCardFromRow); - }, - - savePhaseItem(args: SavePhaseItemArgs): PhaseCard { - ensurePhaseStorageSeeded(); - if (!args.item || typeof args.item !== "object") { - throw new Error("Phase item payload is required."); - } - const now = nowIso(); - const normalized = toPhaseCard( - { - ...args.item, - isBuiltIn: false, - isCustom: true, - updatedAt: now, - createdAt: args.item.createdAt ?? now, - }, - Number(args.item.position ?? 0) - ); - if (!normalized) { - throw new Error("Invalid phase item payload."); - } - if (!normalized.phaseKey.trim().length) { - throw new Error("Phase item phaseKey is required."); - } - upsertPhaseCardRow(normalized); - const row = getPhaseCardRowByKey(normalized.phaseKey); - if (!row) throw new Error("Failed to save phase item."); - return toPhaseCardFromRow(row); - }, - - deletePhaseItem(args: DeletePhaseItemArgs): void { - ensurePhaseStorageSeeded(); - const phaseKey = String(args.phaseKey ?? "").trim(); - if (!phaseKey.length) throw new Error("phaseKey is required."); - const row = getPhaseCardRowByKey(phaseKey); - if (!row) throw new Error(`Phase item not found: ${phaseKey}`); - if (Number(row.is_built_in) === 1) { - throw new Error("Built-in phase items cannot be deleted."); - } - const now = nowIso(); - db.run( - ` - update phase_cards - set archived_at = ?, - updated_at = ? - where project_id = ? - and phase_key = ? - and archived_at is null - `, - [now, now, projectId, phaseKey] - ); - }, - - exportPhaseItems(args: ExportPhaseItemsArgs = {}): ExportPhaseItemsResult { - ensurePhaseStorageSeeded(); - const selectedKeys = Array.isArray(args.phaseKeys) - ? args.phaseKeys.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - const selectedKeySet = selectedKeys.length ? new Set(selectedKeys) : null; - const items = listPhaseCardRows(false) - .filter((row) => !selectedKeySet || selectedKeySet.has(row.phase_key)) - .map(toPhaseCardFromRow); - - let savedPath: string | null = null; - if (profilesDir) { - fs.mkdirSync(profilesDir, { recursive: true }); - const timestamp = nowIso().replace(/[:.]/g, "-"); - const filePath = path.join(profilesDir, `phase-items-${timestamp}.json`); - const payload = { - schema: "ade.phase-items.v1", - exportedAt: nowIso(), - items, - }; - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); - savedPath = filePath; - } - - return { items, savedPath }; - }, - - importPhaseItems(args: ImportPhaseItemsArgs): PhaseCard[] { - ensurePhaseStorageSeeded(); - const filePath = String(args.filePath ?? "").trim(); - if (!filePath.length) throw new Error("filePath is required."); - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw); - const itemsRaw = isRecord(parsed) && Array.isArray(parsed.items) - ? parsed.items - : Array.isArray(parsed) - ? parsed - : []; - const importedCards = toPhaseCards(itemsRaw); - if (importedCards.length === 0) { - throw new Error("Imported phase items payload is empty."); - } - - const now = nowIso(); - const usedKeys = new Set( - listPhaseCardRows(true).map((row) => String(row.phase_key).trim().toLowerCase()).filter((entry) => entry.length > 0) - ); - const ensureUniqueKey = (seed: string): string => { - const base = seed.trim().length > 0 ? seed.trim() : `custom_${randomUUID().slice(0, 8)}`; - let candidate = base; - let suffix = 2; - while (usedKeys.has(candidate.toLowerCase())) { - candidate = `${base}_${suffix}`; - suffix += 1; - } - usedKeys.add(candidate.toLowerCase()); - return candidate; - }; - - const saved: PhaseCard[] = []; - for (const card of importedCards) { - const nextKey = ensureUniqueKey(card.phaseKey); - const nextCard: PhaseCard = { - ...card, - id: `${card.id}:import:${randomUUID()}`, - phaseKey: nextKey, - isBuiltIn: false, - isCustom: true, - updatedAt: now, - createdAt: now, - }; - upsertPhaseCardRow(nextCard); - const row = getPhaseCardRowByKey(nextKey); - if (!row) continue; - saved.push(toPhaseCardFromRow(row)); - } - return saved; - }, - - listPhaseProfiles(args: ListPhaseProfilesArgs = {}): PhaseProfile[] { - ensurePhaseStorageSeeded(); - return listPhaseProfileRows(args.includeArchived === true).map(toPhaseProfile); - }, - - savePhaseProfile(args: SavePhaseProfileArgs): PhaseProfile { - ensurePhaseStorageSeeded(); - if (!args.profile || typeof args.profile !== "object") { - throw new Error("Profile payload is required."); - } - - const now = nowIso(); - const normalized = normalizeProfileInput(args.profile, now); - const validationErrors = validatePhaseSequence(normalized.phases); - if (validationErrors.length > 0) { - throw new Error(`Invalid phase profile: ${validationErrors.join(" ")}`); - } - - for (const card of normalized.phases) { - upsertPhaseCardRow(card); - } - - const candidateId = String(args.profile.id ?? "").trim(); - const existing = candidateId ? getPhaseProfileRow(candidateId) : null; - const profileId = (existing?.id ?? candidateId) || randomUUID(); - const builtIn = existing ? Number(existing.is_built_in) === 1 : false; - const createdAt = existing?.created_at ?? now; - const isDefault = args.profile.isDefault === true || (existing ? Number(existing.is_default) === 1 : false); - const profileName = ensureUniqueProfileName(normalized.name, existing?.id); - - if (existing) { - db.run( - ` - update phase_profiles - set name = ?, - description = ?, - phases_json = ?, - is_default = ?, - archived_at = null, - updated_at = ? - where project_id = ? - and id = ? - `, - [ - profileName, - normalized.description, - JSON.stringify(normalizePhaseCards(normalized.phases)), - isDefault ? 1 : 0, - now, - projectId, - profileId - ] - ); - } else { - db.run( - ` - insert into phase_profiles( - id, - project_id, - name, - description, - phases_json, - is_built_in, - is_default, - archived_at, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, null, ?, ?) - `, - [ - profileId, - projectId, - profileName, - normalized.description, - JSON.stringify(normalizePhaseCards(normalized.phases)), - builtIn ? 1 : 0, - isDefault ? 1 : 0, - createdAt, - now - ] - ); - } - - if (isDefault) { - db.run( - ` - update phase_profiles - set is_default = 0 - where project_id = ? - and id != ? - `, - [projectId, profileId] - ); - } - - const row = getPhaseProfileRow(profileId); - if (!row) throw new Error("Failed to save phase profile."); - - return toPhaseProfile(row); - }, - - deletePhaseProfile(args: DeletePhaseProfileArgs): void { - ensurePhaseStorageSeeded(); - const profileId = String(args.profileId ?? "").trim(); - if (!profileId.length) throw new Error("profileId is required."); - const row = getPhaseProfileRow(profileId); - if (!row) throw new Error(`Phase profile not found: ${profileId}`); - if (Number(row.is_built_in) === 1) { - throw new Error("Built-in profiles cannot be deleted."); - } - - const now = nowIso(); - db.run( - ` - update phase_profiles - set archived_at = ?, - is_default = 0, - updated_at = ? - where project_id = ? - and id = ? - `, - [now, now, projectId, profileId] - ); - db.run( - ` - update mission_phase_overrides - set profile_id = null, - updated_at = ? - where project_id = ? - and profile_id = ? - `, - [now, projectId, profileId] - ); - - const stillDefault = getDefaultPhaseProfileRow(); - if (!stillDefault) { - const fallback = listPhaseProfileRows(false)[0]; - if (fallback) { - db.run( - ` - update phase_profiles - set is_default = 1, - updated_at = ? - where project_id = ? - and id = ? - `, - [nowIso(), projectId, fallback.id] - ); - } - } - }, - - clonePhaseProfile(args: ClonePhaseProfileArgs): PhaseProfile { - ensurePhaseStorageSeeded(); - const profileId = String(args.profileId ?? "").trim(); - if (!profileId.length) throw new Error("profileId is required."); - const source = getPhaseProfileRow(profileId); - if (!source || source.archived_at) { - throw new Error(`Phase profile not found: ${profileId}`); - } - - const sourceProfile = toPhaseProfile(source); - const clonedName = ensureUniqueProfileName(args.name?.trim() || `${sourceProfile.name} (Copy)`); - return this.savePhaseProfile({ - profile: { - name: clonedName, - description: sourceProfile.description, - phases: sourceProfile.phases.map((phase, index) => ({ - ...phase, - id: `${phase.id}:clone:${randomUUID()}`, - isBuiltIn: false, - isCustom: true, - position: index, - createdAt: nowIso(), - updatedAt: nowIso() - })), - isDefault: false - } - }); - }, - - exportPhaseProfile(args: ExportPhaseProfileArgs): ExportPhaseProfileResult { - ensurePhaseStorageSeeded(); - const profileId = String(args.profileId ?? "").trim(); - if (!profileId.length) throw new Error("profileId is required."); - const row = getPhaseProfileRow(profileId); - if (!row || row.archived_at) throw new Error(`Phase profile not found: ${profileId}`); - const profile = toPhaseProfile(row); - - let savedPath: string | null = null; - if (profilesDir) { - fs.mkdirSync(profilesDir, { recursive: true }); - const base = sanitizeFilePart(profile.name); - const timestamp = nowIso().replace(/[:.]/g, "-"); - const filePath = path.join(profilesDir, `${base}-${timestamp}.json`); - const payload = { - schema: "ade.phase-profile.v1", - exportedAt: nowIso(), - profile - }; - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); - savedPath = filePath; - } - - return { profile, savedPath }; - }, - - importPhaseProfile(args: ImportPhaseProfileArgs): PhaseProfile { - ensurePhaseStorageSeeded(); - const filePath = String(args.filePath ?? "").trim(); - if (!filePath.length) throw new Error("filePath is required."); - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw); - const payload = isRecord(parsed) && isRecord(parsed.profile) ? parsed.profile : parsed; - if (!isRecord(payload)) { - throw new Error("Invalid phase profile JSON payload."); - } - - const phases = toPhaseCards(Array.isArray(payload.phases) ? payload.phases : []); - if (!phases.length) { - throw new Error("Imported phase profile has no phases."); - } - - const imported = this.savePhaseProfile({ - profile: { - name: ensureUniqueProfileName(String(payload.name ?? "Imported Profile")), - description: typeof payload.description === "string" ? payload.description : "", - phases: phases.map((phase, index) => ({ - ...phase, - id: `${phase.id}:import:${randomUUID()}`, - isBuiltIn: false, - isCustom: true, - position: index, - createdAt: nowIso(), - updatedAt: nowIso() - })), - isDefault: args.setAsDefault === true - } - }); - return imported; - }, - - getPhaseConfiguration(missionId: string): MissionPhaseConfiguration | null { - const trimmed = missionId.trim(); - if (!trimmed.length) return null; - return resolveMissionPhaseConfiguration(trimmed); - }, - - getDashboard(): MissionDashboardSnapshot { - ensurePhaseStorageSeeded(); - const activeMissions = this.list({ status: "active", limit: 50 }) - .filter((mission) => ACTIVE_MISSION_STATUSES.has(mission.status)); - const activeMissionIds = activeMissions.map((mission) => mission.id); - const phaseGroupsByMission = new Map>(); - const activeWorkerCounts = new Map(); - const orchestratorProgressByMission = new Map(); - - if (activeMissionIds.length > 0) { - const placeholders = activeMissionIds.map(() => "?").join(", "); - const stepRows = db.all( - ` - select - id, - mission_id, - step_index, - title, - detail, - kind, - lane_id, - status, - created_at, - updated_at, - started_at, - completed_at, - metadata_json - from mission_steps - where project_id = ? - and mission_id in (${placeholders}) - order by mission_id asc, step_index asc - `, - [projectId, ...activeMissionIds] - ); - const stepsByMission = new Map(); - for (const stepRow of stepRows) { - const bucket = stepsByMission.get(stepRow.mission_id) ?? []; - bucket.push(toMissionStep(stepRow)); - stepsByMission.set(stepRow.mission_id, bucket); - } - for (const missionId of activeMissionIds) { - phaseGroupsByMission.set(missionId, groupMissionStepsByPhase(stepsByMission.get(missionId) ?? [])); - } - - const workerRows = db.all<{ mission_id: string; count: number }>( - ` - select r.mission_id as mission_id, count(distinct oa.id) as count - from orchestrator_attempts oa - inner join orchestrator_runs r on r.id = oa.run_id - where oa.project_id = ? - and r.project_id = ? - and r.mission_id in (${placeholders}) - and oa.status = 'running' - group by r.mission_id - `, - [projectId, projectId, ...activeMissionIds] - ); - for (const row of workerRows) { - activeWorkerCounts.set(row.mission_id, Number(row.count ?? 0)); - } - - const runRows = db.all( - ` - select id, mission_id, metadata_json - from orchestrator_runs - where project_id = ? - and mission_id in (${placeholders}) - order by mission_id asc, created_at desc - `, - [projectId, ...activeMissionIds] - ); - const latestRunByMission = new Map(); - for (const row of runRows) { - if (!latestRunByMission.has(row.mission_id)) latestRunByMission.set(row.mission_id, row); - } - const latestRunIds = [...latestRunByMission.values()].map((row) => row.id); - if (latestRunIds.length > 0) { - const runPlaceholders = latestRunIds.map(() => "?").join(", "); - const stepRows = db.all<{ - run_id: string; - status: string; - metadata_json: string | null; - }>( - ` - select run_id, status, metadata_json - from orchestrator_steps - where project_id = ? - and run_id in (${runPlaceholders}) - order by run_id asc, step_index asc - `, - [projectId, ...latestRunIds] - ); - const stepsByRun = new Map(); - for (const row of stepRows) { - const bucket = stepsByRun.get(row.run_id) ?? []; - const metadata = safeJsonParse(row.metadata_json ?? null, null); - bucket.push({ - runId: row.run_id, - status: row.status, - metadata: isRecord(metadata) ? metadata : null, - }); - stepsByRun.set(row.run_id, bucket); - } - for (const [missionId, run] of latestRunByMission.entries()) { - const executionSteps = filterExecutionSteps(stepsByRun.get(run.id) ?? []); - if (executionSteps.length === 0) continue; - const completed = executionSteps.filter((step) => - TERMINAL_STEP_STATUSES.has(step.status as any) - ).length; - const currentStep = - executionSteps.find((step) => !TERMINAL_STEP_STATUSES.has(step.status as any)) ?? - executionSteps[executionSteps.length - 1] ?? - null; - const runMetadata = safeJsonParse(run.metadata_json ?? null, null); - const phaseRuntime = isRecord(runMetadata) && isRecord(runMetadata.phaseRuntime) - ? runMetadata.phaseRuntime - : null; - const currentStepMetadata = isRecord(currentStep?.metadata) ? currentStep.metadata : null; - const phaseName = - (typeof currentStepMetadata?.phaseName === "string" && currentStepMetadata.phaseName.trim()) || - (typeof phaseRuntime?.currentPhaseName === "string" && phaseRuntime.currentPhaseName.trim()) || - (typeof currentStepMetadata?.phaseKey === "string" && currentStepMetadata.phaseKey.trim()) || - null; - orchestratorProgressByMission.set(missionId, { - phaseName, - phaseProgress: { - completed, - total: executionSteps.length, - pct: Math.round((completed / executionSteps.length) * 100), - }, - }); - } - } - } - - const active = activeMissions.map((mission) => { - const phaseGroups = phaseGroupsByMission.get(mission.id) ?? []; - const currentPhase = phaseGroups.find((group) => group.completed < group.total) ?? phaseGroups[phaseGroups.length - 1] ?? null; - const orchestratorProgress = orchestratorProgressByMission.get(mission.id) ?? null; - const phaseProgress = currentPhase - ? { - completed: currentPhase.completed, - total: currentPhase.total, - pct: currentPhase.total > 0 ? Math.round((currentPhase.completed / currentPhase.total) * 100) : 0 - } - : orchestratorProgress?.phaseProgress ?? { completed: 0, total: 0, pct: 0 }; - const activeWorkers = activeWorkerCounts.get(mission.id) ?? 0; - - const startedAtMs = mission.startedAt ? Date.parse(mission.startedAt) : NaN; - const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : 0; - const remainingSteps = Math.max(0, mission.totalSteps - mission.completedSteps); - const estimatedRemainingMs = - mission.totalSteps > 0 && mission.completedSteps > 0 - ? Math.round((elapsedMs / mission.completedSteps) * remainingSteps) - : null; - - return { - mission, - phaseName: currentPhase?.name ?? orchestratorProgress?.phaseName ?? null, - phaseProgress, - activeWorkers, - elapsedMs, - estimatedRemainingMs - }; - }); - - const recentRows = db.all( - `${baseMissionSelect} - and m.archived_at is null - and m.status in ('completed', 'failed', 'canceled') - order by coalesce(m.completed_at, m.updated_at) desc - limit 12`, - [projectId] - ); - const recent = recentRows.map((row) => { - const mission = toMissionSummary(row); - const started = mission.startedAt ? Date.parse(mission.startedAt) : NaN; - const completed = mission.completedAt ? Date.parse(mission.completedAt) : NaN; - const durationMs = - Number.isFinite(started) && Number.isFinite(completed) - ? Math.max(0, completed - started) - : 0; - return { - mission, - durationMs, - costEstimateUsd: estimateMissionCostUsd(mission.id), - action: mapMissionQuickAction(mission.status) - }; - }); - - const weekStart = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const weeklyRows = db.all( - `${baseMissionSelect} - and m.archived_at is null - and m.created_at >= ? - order by m.created_at desc`, - [projectId, weekStart] - ); - const weeklyMissions = weeklyRows.map(toMissionSummary); - const terminal = weeklyMissions.filter((mission) => TERMINAL_MISSION_STATUSES.has(mission.status)); - const successCount = terminal.filter((mission) => mission.status === "completed").length; - const durationValues = terminal - .map((mission) => { - const started = mission.startedAt ? Date.parse(mission.startedAt) : NaN; - const completed = mission.completedAt ? Date.parse(mission.completedAt) : NaN; - if (!Number.isFinite(started) || !Number.isFinite(completed)) return null; - return Math.max(0, completed - started); - }) - .filter((value): value is number => value != null); - const avgDurationMs = - durationValues.length > 0 - ? Math.round(durationValues.reduce((sum, value) => sum + value, 0) / durationValues.length) - : 0; - const totalCostUsd = Number( - weeklyMissions - .map((mission) => estimateMissionCostUsd(mission.id) ?? 0) - .reduce((sum, value) => sum + value, 0) - .toFixed(4) - ); - - return { - active, - recent, - weekly: { - missions: weeklyMissions.length, - successRate: terminal.length > 0 ? Number((successCount / terminal.length).toFixed(4)) : 0, - avgDurationMs, - totalCostUsd - } - }; - }, - - logEvent(args: { - missionId: string; - eventType: string; - actor?: string; - summary: string; - payload?: Record | null; - }): MissionEvent { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId.length) throw new Error("missionId is required."); - if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); - const event = recordEvent({ - missionId, - eventType: args.eventType, - actor: args.actor ?? "system", - summary: args.summary, - payload: args.payload ?? null - }); - db.run( - "update missions set updated_at = ? where id = ? and project_id = ?", - [nowIso(), missionId, projectId] - ); - emit({ missionId, reason: "event-logged" }); - return event; - }, - - create(args: CreateMissionInternalArgs): MissionDetail { - const prompt = normalizePrompt(args.prompt ?? ""); - if (!prompt.length) { - throw new Error("Mission prompt is required."); - } - - const title = deriveMissionTitle(prompt, args.title); - const laneId = coerceNullableString(args.laneId); - assertLaneExists(laneId); - assertMissionLaunchLaneAvailable(laneId); - const priority = args.priority ?? "normal"; - const executionMode = args.executionMode ?? "local"; - const targetMachineId = coerceNullableString(args.targetMachineId); - const plannerPlan = args.plannerPlan ?? null; - const launchMode = args.launchMode === "manual" ? "manual" : "autopilot"; - const autostart = args.autostart !== false; - const autopilotExecutor = args.autopilotExecutor ?? "opencode"; - const launchAgentRuntime = normalizeAgentRuntimeFlags( - isRecord(args.agentRuntime) ? (args.agentRuntime as Record) : {} - ); - const launchTeamRuntime = (() => { - if (!args.teamRuntime) return undefined; - return { - ...args.teamRuntime, - allowParallelAgents: - typeof args.teamRuntime.allowParallelAgents === "boolean" - ? args.teamRuntime.allowParallelAgents - : launchAgentRuntime.allowParallelAgents, - allowSubAgents: - typeof args.teamRuntime.allowSubAgents === "boolean" - ? args.teamRuntime.allowSubAgents - : launchAgentRuntime.allowSubAgents, - allowClaudeAgentTeams: - typeof args.teamRuntime.allowClaudeAgentTeams === "boolean" - ? args.teamRuntime.allowClaudeAgentTeams - : launchAgentRuntime.allowClaudeAgentTeams - }; - })(); - // Resolve execution policy from args when provided by the caller. - const executionPolicyArg = args.executionPolicy && typeof args.executionPolicy === "object" - ? (args.executionPolicy as Partial) - : null; - const storedExecutionPolicy = executionPolicyArg - ? { - ...executionPolicyArg, - prStrategy: undefined, - finalizationPolicyKind: "result_lane" as const, - } - : { - finalizationPolicyKind: "result_lane" as const, - }; - - // Build mission-level settings from new args fields - const missionLevelSettings: import("../../../shared/types").MissionLevelSettings = { - ...(args.recoveryLoop ? { recoveryLoop: args.recoveryLoop } : {}), - ...(executionPolicyArg?.integrationPr ? { integrationPr: executionPolicyArg.integrationPr } : {}), - ...(executionPolicyArg?.teamRuntime ? { teamRuntime: executionPolicyArg.teamRuntime } : {}), - prStrategy: executionPolicyArg?.prStrategy ?? { kind: "manual" }, - finalizationPolicyKind: "result_lane", - }; - - ensurePhaseStorageSeeded(); - const requestedProfileId = coerceNullableString(args.phaseProfileId); - const selectedProfileRow = requestedProfileId - ? getPhaseProfileRow(requestedProfileId) - : getDefaultPhaseProfileRow(); - if (requestedProfileId && !selectedProfileRow) { - throw new Error(`Phase profile not found: ${requestedProfileId}`); - } - const selectedProfile = selectedProfileRow ? toPhaseProfile(selectedProfileRow) : null; - const overridePhasesRaw = - Array.isArray(args.phaseOverride) && args.phaseOverride.length > 0 - ? toPhaseCards(args.phaseOverride) - : []; - const hasExplicitOverride = Array.isArray(args.phaseOverride) && args.phaseOverride.length > 0; - if (hasExplicitOverride && overridePhasesRaw.length === 0) { - throw new Error("Invalid mission phase override payload."); - } - const selectedPhases = normalizePhaseCards( - ensurePlanningPhase( - overridePhasesRaw.length > 0 - ? overridePhasesRaw - : selectedProfile?.phases?.length - ? selectedProfile.phases - : createBuiltInPhaseCards(nowIso()) - ) - ); - const phaseErrors = validatePhaseSequence(selectedPhases); - if (phaseErrors.length > 0) { - throw new Error(`Invalid mission phase sequence: ${phaseErrors.join(" ")}`); - } - - // Deterministic seed planner retired — coordinator builds the DAG at runtime. - // If the caller provided planned steps, use them; otherwise default to empty (coordinator plans). - const plannerClarifyingQuestions = plannerPlan - ? normalizePlannerClarifyingQuestions(plannerPlan.clarifyingQuestions) - : []; - const plannerClarifyingAnswers = plannerPlan - ? normalizePlannerClarifyingAnswers(plannerPlan.clarifyingAnswers) - : []; - const rawStepsToPersist: MissionPlanStepDraft[] = - Array.isArray(args.plannedSteps) && args.plannedSteps.length - ? args.plannedSteps - .map((step, index) => { - const candidate: Record = isRecord(step) ? step : {}; - const rawIndex = Number(candidate.index); - const title = typeof candidate.title === "string" && candidate.title.trim().length > 0 - ? candidate.title.trim() - : `Step ${index + 1}`; - const detail = typeof candidate.detail === "string" ? candidate.detail : ""; - const kind = typeof candidate.kind === "string" && candidate.kind.trim().length > 0 - ? candidate.kind.trim() - : "task"; - return { - index: Number.isFinite(rawIndex) ? Math.max(0, Math.floor(rawIndex)) : index, - title, - detail, - kind, - metadata: isRecord(candidate.metadata) ? candidate.metadata : {}, - }; - }) - .sort((a: MissionPlanStepDraft, b: MissionPlanStepDraft) => a.index - b.index || a.title.localeCompare(b.title)) - : []; - const stepsToPersist = applyPhaseCardsToPlanSteps( - rawStepsToPersist.map((step) => ({ - ...step, - metadata: { - ...(isRecord(step.metadata) ? step.metadata : {}), - ...(plannerClarifyingQuestions.length ? { plannerClarifyingQuestions } : {}), - ...(plannerClarifyingAnswers.length ? { plannerClarifyingAnswers } : {}) - } - })), - selectedPhases - ); - - const id = randomUUID(); - const createdAt = nowIso(); - const missionMetadata = { - source: "manual", - version: 3, - launch: { - autostart, - runMode: launchMode, - autopilotExecutor, - agentRuntime: launchAgentRuntime, - employeeAgentId: coerceNullableString(args.employeeAgentId), - ...(args.modelConfig ? { modelConfig: args.modelConfig } : {}), - ...(args.modelConfig && typeof args.modelConfig === "object" ? { intelligenceConfig: args.modelConfig.intelligenceConfig } : {}), - ...(launchTeamRuntime ? { teamRuntime: launchTeamRuntime } : {}), - ...(args.permissionConfig ? { permissionConfig: args.permissionConfig } : {}), - phaseProfileId: selectedProfile?.id ?? null, - hasPhaseOverride: hasExplicitOverride - }, - executionPolicy: storedExecutionPolicy, - missionLevelSettings, - finalization: { - kind: "result_lane", - resultLanePolicy: "single_visible_lane", - }, - phaseConfiguration: { - profileId: selectedProfile?.id ?? null, - phaseKeys: selectedPhases.map((phase) => phase.phaseKey), - phaseCount: selectedPhases.length, - phases: selectedPhases - }, - plannerPlan: plannerPlan - ? { - schemaVersion: plannerPlan.schemaVersion, - clarifyingQuestions: plannerClarifyingQuestions, - clarifyingAnswers: plannerClarifyingAnswers, - missionSummary: plannerPlan.missionSummary, - assumptions: plannerPlan.assumptions, - risks: plannerPlan.risks, - stepCount: plannerPlan.steps.length, - handoffPolicy: plannerPlan.handoffPolicy - } - : null - }; - - db.run( - ` - insert into missions( - id, - project_id, - lane_id, - title, - prompt, - status, - priority, - execution_mode, - target_machine_id, - outcome_summary, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, 'queued', ?, ?, ?, null, null, ?, ?, ?, null, null) - `, - [ - id, - projectId, - laneId, - title, - prompt, - priority, - executionMode, - targetMachineId, - JSON.stringify(missionMetadata), - createdAt, - createdAt - ] - ); - - stepsToPersist.forEach((step, index) => { - const stepId = randomUUID(); - db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, null, null) - `, - [ - stepId, - id, - projectId, - index, - step.title, - step.detail, - step.kind, - laneId, - JSON.stringify(step.metadata), - createdAt, - createdAt - ] - ); - }); - - upsertMissionPhaseOverride({ - missionId: id, - profileId: selectedProfile?.id ?? null, - phases: selectedPhases - }); - - recordEvent({ - missionId: id, - eventType: "mission_created", - actor: "user", - summary: "Mission created from plain-English prompt.", - payload: { - title, - laneId, - priority, - executionMode, - targetMachineId, - preview: summarizePrompt(prompt), - plannerVersion: "coordinator", - plannerStrategy: plannerPlan?.missionSummary.strategy ?? "coordinator", - plannerStepCount: stepsToPersist.length, - plannerKeywords: [], - plannerEngineRequested: args.plannerEngine ?? "auto", - plannerEngineResolved: null, - plannerDegraded: false, - phaseProfileId: selectedProfile?.id ?? null, - phaseKeys: selectedPhases.map((phase) => phase.phaseKey) - } - }); - - recordEvent({ - missionId: id, - eventType: "mission_phase_configured", - actor: "system", - summary: `Phase configuration loaded (${selectedPhases.length} phases).`, - payload: { - profileId: selectedProfile?.id ?? null, - phaseKeys: selectedPhases.map((phase) => phase.phaseKey), - hasOverride: hasExplicitOverride - } - }); - - if (plannerPlan) { - const planSummaryText = [ - plannerPlan.missionSummary?.strategy ? `Strategy: ${plannerPlan.missionSummary.strategy}` : null, - plannerPlan.missionSummary?.objective ? `Objective: ${plannerPlan.missionSummary.objective}` : null, - plannerPlan.assumptions?.length ? `Assumptions:\n${plannerPlan.assumptions.map((entry) => `- ${entry}`).join("\n")}` : null, - plannerPlan.risks?.length ? `Risks:\n${plannerPlan.risks.map((entry) => `- ${entry}`).join("\n")}` : null, - plannerPlan.steps.length - ? `Plan Steps:\n${plannerPlan.steps.map((step, index) => `${index + 1}. ${step.name}`).join("\n")}` - : null, - ].filter((entry): entry is string => Boolean(entry)).join("\n\n"); - - if (planSummaryText.trim().length > 0) { - insertArtifact({ - missionId: id, - artifactType: "summary", - title: "Generated mission plan", - description: planSummaryText, - createdBy: "system", - metadata: { - plannerStepCount: plannerPlan.steps.length, - source: "planner_plan", - }, - }); - } - } - - db.flushNow(); - emit({ missionId: id, reason: "created" }); - const detail = this.get(id); - if (!detail) throw new Error("Mission creation failed"); - return detail; - }, - - update(args: UpdateMissionArgs): MissionDetail { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("Mission id is required."); - - const existing = db.get<{ - id: string; - title: string; - prompt: string; - lane_id: string | null; - status: string; - priority: string; - execution_mode: string; - target_machine_id: string | null; - outcome_summary: string | null; - last_error: string | null; - }>( - ` - select - id, - title, - prompt, - lane_id, - status, - priority, - execution_mode, - target_machine_id, - outcome_summary, - last_error - from missions - where id = ? - and project_id = ? - limit 1 - `, - [missionId, projectId] - ); - - if (!existing) { - throw new Error(`Mission not found: ${missionId}`); - } - - const nextLaneId = args.laneId !== undefined ? coerceNullableString(args.laneId) : existing.lane_id; - assertLaneExists(nextLaneId); - assertMissionLaunchLaneAvailable(nextLaneId, missionId); - - const nextPrompt = args.prompt !== undefined ? normalizePrompt(args.prompt) : existing.prompt; - if (!nextPrompt.length) throw new Error("Mission prompt cannot be empty."); - const nextTitle = args.title !== undefined ? deriveMissionTitle(nextPrompt, args.title) : existing.title; - - const nextPriority = args.priority ?? normalizeMissionPriority(existing.priority); - const nextExecutionMode = args.executionMode ?? normalizeExecutionMode(existing.execution_mode); - const nextTargetMachineId = - args.targetMachineId !== undefined ? coerceNullableString(args.targetMachineId) : existing.target_machine_id; - const nextOutcomeSummary = - args.outcomeSummary !== undefined ? sanitizeOptionalText(args.outcomeSummary) : existing.outcome_summary; - const nextLastError = args.lastError !== undefined ? sanitizeOptionalText(args.lastError) : existing.last_error; - - const updatedAt = nowIso(); - - if (args.status) { - upsertMissionStatus({ - missionId, - nextStatus: args.status, - updatedAt, - summary: `Mission status changed to ${args.status}.` - }); - } - - db.run( - ` - update missions - set title = ?, - prompt = ?, - lane_id = ?, - priority = ?, - execution_mode = ?, - target_machine_id = ?, - outcome_summary = ?, - last_error = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [ - nextTitle, - nextPrompt, - nextLaneId, - nextPriority, - nextExecutionMode, - nextTargetMachineId, - nextOutcomeSummary, - nextLastError, - updatedAt, - missionId, - projectId - ] - ); - - const changedFields: string[] = []; - if (nextTitle !== existing.title) changedFields.push("title"); - if (nextPrompt !== existing.prompt) changedFields.push("prompt"); - if (nextLaneId !== existing.lane_id) changedFields.push("laneId"); - if (nextPriority !== existing.priority) changedFields.push("priority"); - if (nextExecutionMode !== existing.execution_mode) changedFields.push("executionMode"); - if (nextTargetMachineId !== existing.target_machine_id) changedFields.push("targetMachineId"); - if (nextOutcomeSummary !== existing.outcome_summary) changedFields.push("outcomeSummary"); - if (nextLastError !== existing.last_error) changedFields.push("lastError"); - if (changedFields.length) { - recordEvent({ - missionId, - eventType: "mission_updated", - actor: "user", - summary: `Mission updated (${changedFields.join(", ")}).`, - payload: { changedFields } - }); - } - - if (nextOutcomeSummary && args.outcomeSummary !== undefined) { - const hasSummaryArtifact = db.get<{ id: string }>( - ` - select id - from mission_artifacts - where project_id = ? - and mission_id = ? - and artifact_type = 'summary' - order by created_at desc - limit 1 - `, - [projectId, missionId] - ); - - if (!hasSummaryArtifact?.id) { - const summaryArtifact = insertArtifact({ - missionId, - artifactType: "summary", - title: "Mission outcome summary", - description: nextOutcomeSummary, - createdBy: "system" - }); - recordEvent({ - missionId, - eventType: "mission_artifact_added", - actor: "system", - summary: "Outcome summary artifact recorded.", - payload: { - artifactId: summaryArtifact.id, - artifactType: summaryArtifact.artifactType - } - }); - } - } - - db.flushNow(); - emit({ missionId, reason: "updated" }); - const detail = this.get(missionId); - if (!detail) throw new Error("Mission update failed"); - return detail; - }, - - patchMetadata(missionId: string, patch: Record): void { - const id = missionId.trim(); - if (!id.length) throw new Error("Mission id is required."); - if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); - - const row = db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? and project_id = ? limit 1`, - [id, projectId] - ); - const base = safeParseRecord(row?.metadata_json ?? null); - const next = { ...base, ...patch }; - db.run( - `update missions set metadata_json = ?, updated_at = ? where id = ? and project_id = ?`, - [JSON.stringify(next), nowIso(), id, projectId] - ); - db.flushNow(); - }, - - beginStart(missionId: string, claimToken?: string | null): boolean { - const id = missionId.trim(); - if (!id.length) throw new Error("Mission id is required."); - if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); - return beginMissionStart(id, claimToken ?? null); - }, - - clearQueueClaim(missionId: string, claimToken?: string | null): void { - const id = missionId.trim(); - if (!id.length) throw new Error("Mission id is required."); - if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); - clearMissionQueueClaim(id, claimToken ?? null); - }, - - setMissionLane(args: { missionId: string; laneId: string | null }): void { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("missionId is required."); - if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); - const laneId = coerceNullableString(args.laneId); - assertLaneExists(laneId); - assertMissionLaunchLaneAvailable(laneId, missionId); - db.run( - ` - update missions - set mission_lane_id = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [laneId, nowIso(), missionId, projectId] - ); - emit({ missionId, reason: "mission-lane-updated" }); - }, - - setResultLane(args: { missionId: string; laneId: string | null }): void { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("missionId is required."); - if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); - const laneId = coerceNullableString(args.laneId); - assertLaneExists(laneId); - assertMissionLaunchLaneAvailable(laneId, missionId); - db.run( - ` - update missions - set result_lane_id = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [laneId, nowIso(), missionId, projectId] - ); - emit({ missionId, reason: "result-lane-updated" }); - }, - - archive(args: ArchiveMissionArgs): void { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("missionId is required."); - const row = db.get<{ id: string; status: string; archived_at: string | null }>( - ` - select id, status, archived_at - from missions - where id = ? - and project_id = ? - limit 1 - `, - [missionId, projectId] - ); - if (!row?.id) throw new Error(`Mission not found: ${missionId}`); - if (row.archived_at) return; - - const status = normalizeMissionStatus(row.status); - if (!TERMINAL_MISSION_STATUSES.has(status)) { - throw new Error("Only completed, failed, or canceled missions can be archived."); - } - - const archivedAt = nowIso(); - recordEvent({ - missionId, - eventType: "mission_archived", - actor: "user", - summary: "Mission archived from the Missions tab.", - payload: { - archivedAt, - status, - } - }); - db.run( - ` - update missions - set archived_at = ?, - updated_at = ? - where id = ? - and project_id = ? - and archived_at is null - `, - [archivedAt, archivedAt, missionId, projectId] - ); - db.flushNow(); - emit({ missionId, reason: "archived" }); - }, - - delete(args: DeleteMissionArgs): void { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("missionId is required."); - if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); - - const runRows = db.all<{ id: string }>( - ` - select id - from orchestrator_runs - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - const runIds = runRows.map((row) => row.id); - const runPlaceholders = runIds.map(() => "?").join(", "); - - // Delete dependents in FK-safe order because mission/orchestrator tables do not use cascade deletes. - db.run( - ` - delete from mission_step_handoffs - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - - if (runIds.length) { - db.run( - ` - update orchestrator_attempts - set context_snapshot_id = null - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_attempt_runtime - where attempt_id in ( - select id - from orchestrator_attempts - where project_id = ? - and run_id in (${runPlaceholders}) - ) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_runtime_events - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_timeline_events - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_claims - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_chat_messages - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_worker_digests - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_lane_decisions - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_context_checkpoints - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_worker_checkpoints - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_metrics_samples - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_context_snapshots - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_chat_threads - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_attempts - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - db.run( - ` - delete from orchestrator_steps - where project_id = ? - and run_id in (${runPlaceholders}) - `, - [projectId, ...runIds] - ); - // Delete team runtime tables (FK → orchestrator_runs) - db.run( - `delete from orchestrator_team_members where run_id in (${runPlaceholders})`, - [...runIds] - ); - db.run( - `delete from orchestrator_run_state where run_id in (${runPlaceholders})`, - [...runIds] - ); - } - - db.run( - ` - delete from mission_metrics_config - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_chat_messages - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_chat_threads - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_worker_digests - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_lane_decisions - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_context_checkpoints - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_worker_checkpoints - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from orchestrator_metrics_samples - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - - // Delete team runtime tables (FK → orchestrator_runs) before deleting runs - db.run( - `delete from orchestrator_team_members where mission_id = ?`, - [missionId] - ); - db.run( - `delete from orchestrator_run_state where run_id in ( - select id from orchestrator_runs where project_id = ? and mission_id = ? - )`, - [projectId, missionId] - ); - - db.run( - ` - delete from orchestrator_runs - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from mission_interventions - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from mission_artifacts - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from mission_events - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from mission_steps - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from mission_phase_overrides - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - update lanes - set mission_id = null, - lane_role = null - where project_id = ? - and mission_id = ? - `, - [projectId, missionId] - ); - db.run( - ` - delete from missions - where project_id = ? - and id = ? - `, - [projectId, missionId] - ); - - db.flushNow(); - emit({ missionId, reason: "deleted" }); - }, - - updateStep(args: UpdateMissionStepArgs): MissionStep { - const missionId = args.missionId.trim(); - const stepId = args.stepId.trim(); - if (!missionId.length || !stepId.length) throw new Error("missionId and stepId are required."); - - const step = db.get( - ` - select - id, - mission_id, - step_index, - title, - detail, - kind, - lane_id, - status, - created_at, - updated_at, - started_at, - completed_at, - metadata_json - from mission_steps - where id = ? - and mission_id = ? - and project_id = ? - limit 1 - `, - [stepId, missionId, projectId] - ); - - if (!step) { - throw new Error(`Mission step not found: ${stepId}`); - } - - const previous = normalizeStepStatus(step.status); - const next = args.status; - if (!isValidMissionStepTransition(previous, next)) { - throw new Error(`Invalid mission step transition: ${previous} -> ${next}`); - } - - const updatedAt = nowIso(); - let startedAt = step.started_at; - let completedAt = step.completed_at; - - if (next === "running") { - if (!startedAt) startedAt = updatedAt; - completedAt = null; - } - - if (next === "pending") { - startedAt = null; - completedAt = null; - } - - if (next === "succeeded" || next === "failed" || next === "skipped" || next === "canceled") { - if (!startedAt) startedAt = updatedAt; - completedAt = updatedAt; - } - - if (next === "blocked") { - completedAt = null; - } - - db.run( - ` - update mission_steps - set status = ?, - started_at = ?, - completed_at = ?, - updated_at = ? - where id = ? - and mission_id = ? - and project_id = ? - `, - [next, startedAt, completedAt, updatedAt, stepId, missionId, projectId] - ); - - const note = sanitizeOptionalText(args.note ?? null); - recordEvent({ - missionId, - eventType: "mission_step_updated", - actor: "user", - summary: `Step ${Number(step.step_index) + 1} set to ${next}.`, - payload: { - stepId, - stepIndex: Number(step.step_index), - stepTitle: step.title, - from: previous, - to: next, - ...(note ? { note } : {}) - } - }); - - if (next === "failed") { - const stepMetadata = safeParseRecord(step.metadata_json); - const stepKey = typeof stepMetadata?.stepKey === "string" && stepMetadata.stepKey.trim() - ? stepMetadata.stepKey.trim() - : null; - const intervention = insertIntervention({ - missionId, - interventionType: "failed_step", - title: `Step failed: ${step.title}`, - body: note ?? "A mission step was marked as failed and needs attention.", - requestedAction: "Review the failure and decide whether to continue, retry, or cancel.", - metadata: { - stepId, - ...(stepKey ? { stepKey } : {}), - }, - }); - - db.run( - ` - update missions - set last_error = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [note ?? step.title, updatedAt, missionId, projectId] - ); - - upsertMissionStatus({ - missionId, - nextStatus: "intervention_required", - updatedAt, - summary: "Mission paused for intervention after step failure.", - payload: { - interventionId: intervention.id, - stepId - } - }); - } - - emit({ missionId, reason: "step-updated" }); - - const nextStep = db.get( - ` - select - id, - mission_id, - step_index, - title, - detail, - kind, - lane_id, - status, - created_at, - updated_at, - started_at, - completed_at, - metadata_json - from mission_steps - where id = ? - and mission_id = ? - and project_id = ? - limit 1 - `, - [stepId, missionId, projectId] - ); - - if (!nextStep) throw new Error("Mission step update failed"); - return toMissionStep(nextStep); - }, - - addArtifact(args: AddMissionArtifactArgs): MissionArtifact { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("missionId is required."); - if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); - - const artifact = insertArtifact({ - missionId, - artifactType: args.artifactType, - title: args.title, - description: args.description, - uri: args.uri, - laneId: args.laneId, - metadata: args.metadata, - createdBy: typeof args.createdBy === "string" && args.createdBy.trim().length > 0 ? args.createdBy.trim() : "user" - }); - - recordEvent({ - missionId, - eventType: "mission_artifact_added", - actor: typeof args.actor === "string" && args.actor.trim().length > 0 ? args.actor.trim() : "user", - summary: `Artifact added: ${artifact.title}`, - payload: { - artifactId: artifact.id, - artifactType: artifact.artifactType, - uri: artifact.uri - } - }); - - db.run( - "update missions set updated_at = ? where id = ? and project_id = ?", - [nowIso(), missionId, projectId] - ); - emit({ missionId, reason: "artifact-added" }); - return artifact; - }, - - addIntervention(args: AddMissionInterventionArgs): MissionIntervention { - const missionId = args.missionId.trim(); - if (!missionId.length) throw new Error("missionId is required."); - if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); - - // VAL-INTV-001 / VAL-INTV-002: Deduplicate interventions. - // Check for an existing open intervention that matches this one. - // For failed_step interventions, match by stepId in metadata. - // For budget_limit_reached / provider_unreachable / unrecoverable_error, - // match by interventionType alone (at most one open per type). - const meta = args.metadata ?? null; - const metaStepId = meta && typeof (meta as Record).stepId === "string" - ? ((meta as Record).stepId as string).trim() - : null; - - const existingOpenRows = db.all( - `select id, mission_id, intervention_type, status, title, body, - requested_action, resolution_kind, resolution_note, lane_id, - created_at, updated_at, resolved_at, metadata_json - from mission_interventions - where mission_id = ? and project_id = ? and status = 'open' - and intervention_type = ?`, - [missionId, projectId, args.interventionType] - ); - - for (const row of existingOpenRows) { - if (args.interventionType === "failed_step" && metaStepId) { - // Match by stepId in metadata - const rowMeta = safeParseRecord(row.metadata_json); - const rowStepId = rowMeta && typeof rowMeta.stepId === "string" - ? rowMeta.stepId.trim() - : null; - if (rowStepId === metaStepId) { - // Return existing intervention — skip duplicate creation - return toMissionIntervention(row); - } - } else if ( - args.interventionType === "budget_limit_reached" || - args.interventionType === "provider_unreachable" || - args.interventionType === "unrecoverable_error" - ) { - // At most one open intervention per type - return toMissionIntervention(row); - } - } - - const intervention = insertIntervention({ - missionId, - interventionType: args.interventionType, - title: args.title, - body: args.body, - requestedAction: args.requestedAction, - laneId: args.laneId, - metadata: args.metadata - }); - - recordEvent({ - missionId, - eventType: "mission_intervention_added", - actor: "user", - summary: `Intervention added: ${intervention.title}`, - payload: { - interventionId: intervention.id, - interventionType: intervention.interventionType - } - }); - - const shouldPauseMission = args.pauseMission !== false; - if (shouldPauseMission) { - if (onBlockingInterventionAdded) { - void Promise.resolve( - onBlockingInterventionAdded({ - missionId, - intervention, - }), - ).catch(() => {}); - } else { - upsertMissionStatus({ - missionId, - nextStatus: "intervention_required", - summary: "Mission moved to intervention required." - }); - } - } - - db.run( - "update missions set updated_at = ? where id = ? and project_id = ?", - [nowIso(), missionId, projectId] - ); - emit({ missionId, reason: "intervention-added" }); - return intervention; - }, - - resolveIntervention(args: ResolveMissionInterventionArgs): MissionIntervention { - const missionId = args.missionId.trim(); - const interventionId = args.interventionId.trim(); - if (!missionId.length || !interventionId.length) { - throw new Error("missionId and interventionId are required."); - } - - const row = db.get( - ` - select - id, - mission_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - created_at, - updated_at, - resolved_at, - metadata_json - from mission_interventions - where id = ? - and mission_id = ? - and project_id = ? - limit 1 - `, - [interventionId, missionId, projectId] - ); - - if (!row) { - throw new Error(`Intervention not found: ${interventionId}`); - } - - const targetStatus = args.status; - const resolutionKind = args.resolutionKind ?? null; - const note = sanitizeOptionalText(args.note ?? null); - const resolvedAt = nowIso(); - - db.run( - ` - update mission_interventions - set status = ?, - resolution_kind = ?, - resolution_note = ?, - resolved_at = ?, - updated_at = ? - where id = ? - and mission_id = ? - and project_id = ? - `, - [targetStatus, resolutionKind, note, resolvedAt, resolvedAt, interventionId, missionId, projectId] - ); - - const resolutionSummary = (() => { - if (resolutionKind === "accept_defaults") return `${row.title} — user accepted defaults.`; - if (resolutionKind === "skip_question") return `${row.title} — user skipped the question.`; - if (resolutionKind === "cancel_run") return `${row.title} — user canceled the run.`; - if (resolutionKind === "stale_phase_approval") return `${row.title} — stale phase approval closed.`; - if (targetStatus === "dismissed") return `${row.title} — dismissed.`; - return `${row.title} — answer provided.`; - })(); - - recordEvent({ - missionId, - eventType: "mission_intervention_resolved", - actor: "user", - summary: resolutionSummary, - payload: { - interventionId, - status: targetStatus, - ...(resolutionKind ? { resolutionKind } : {}), - ...(note ? { note } : {}) - } - }); - - - const openCount = db.get<{ count: number }>( - ` - select count(*) as count - from mission_interventions - where project_id = ? - and mission_id = ? - and status = 'open' - `, - [projectId, missionId] - ); - - if ((openCount?.count ?? 0) === 0) { - const mission = db.get<{ status: string }>( - "select status from missions where id = ? and project_id = ? limit 1", - [missionId, projectId] - ); - if (mission && normalizeMissionStatus(mission.status) === "intervention_required") { - upsertMissionStatus({ - missionId, - nextStatus: "in_progress", - summary: "All interventions resolved. Mission resumed." - }); - } - } - - db.run( - "update missions set updated_at = ? where id = ? and project_id = ?", - [resolvedAt, missionId, projectId] - ); - emit({ missionId, reason: "intervention-resolved" }); - - const updated = db.get( - ` - select - id, - mission_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - created_at, - updated_at, - resolved_at, - metadata_json - from mission_interventions - where id = ? - and mission_id = ? - and project_id = ? - limit 1 - `, - [interventionId, missionId, projectId] - ); - if (!updated) throw new Error("Intervention update failed"); - const intervention = toMissionIntervention(updated); - try { - void onInterventionResolved?.({ - missionId, - intervention, - }); - } catch { - // Knowledge capture should never block intervention resolution. - } - return intervention; - }, - - // ── Concurrency Guard ──────────────────────────────────────── - canStartMission(missionId: string): MissionConcurrencyCheckResult { - const activeMissions = this.list({ status: "active" }) - .filter(m => ACTIVE_MISSION_STATUSES.has(m.status) && m.id !== missionId); - const maxConcurrent = activeConcurrencyConfig.maxConcurrentMissions; - if (activeMissions.length >= maxConcurrent) { - const queuedMissions = this.list({}) - .filter(m => m.status === "queued") - .sort((a, b) => - (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2) - || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - const queuePosition = queuedMissions.findIndex(m => m.id === missionId); - return { - allowed: false, - reason: `${activeMissions.length} missions already active (max: ${maxConcurrent})`, - queuePosition: queuePosition >= 0 ? queuePosition + 1 : undefined - }; - } - return { allowed: true }; - }, - - isLaneClaimed(laneId: string, excludeMissionId?: string): MissionLaneClaimCheckResult { - if (!activeConcurrencyConfig.laneExclusivity) return { claimed: false }; - if (!laneId) return { claimed: false }; - const laneConflict = getMissionLaunchLaneConflict(laneId, excludeMissionId); - if (laneConflict) { - const laneOwner = db.get<{ mission_id: string | null }>( - `select mission_id from lanes where id = ? and project_id = ? limit 1`, - [laneId, projectId] - ); - const resultOwner = db.get<{ id: string }>( - ` - select id - from missions - where project_id = ? - and result_lane_id = ? - and archived_at is null - and (? is null or id != ?) - limit 1 - `, - [projectId, laneId, excludeMissionId ?? null, excludeMissionId ?? null] - ); - return { - claimed: true, - byMissionId: laneOwner?.mission_id ?? resultOwner?.id, - reason: laneConflict, - }; - } - const activeMissions = this.list({ status: "active" }) - .filter(m => ACTIVE_MISSION_STATUSES.has(m.status) && m.id !== excludeMissionId); - for (const mission of activeMissions) { - if ( - mission.laneId === laneId - || mission.missionLaneId === laneId - || mission.resultLaneId === laneId - ) { - return { claimed: true, byMissionId: mission.id, reason: "Lane is already assigned to another active mission." }; - } - const detail = this.get(mission.id); - if (detail) { - const hasRunningStepOnLane = detail.steps.some( - s => s.laneId === laneId && s.status === "running" - ); - if (hasRunningStepOnLane) { - return { claimed: true, byMissionId: mission.id, reason: "Lane has an active mission step running on it." }; - } - } - } - return { claimed: false }; - }, - - consumeQueueClaim(missionId: string, claimToken?: string | null): boolean { - const id = missionId.trim(); - if (!id.length) throw new Error("Mission id is required."); - if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); - const before = db.get<{ queue_claim_token: string | null }>( - `select queue_claim_token from missions where id = ? and project_id = ? limit 1`, - [id, projectId] - ); - clearMissionQueueClaim(id, claimToken ?? null); - const after = db.get<{ queue_claim_token: string | null }>( - `select queue_claim_token from missions where id = ? and project_id = ? limit 1`, - [id, projectId] - ); - return before?.queue_claim_token !== after?.queue_claim_token; - }, - - processQueue(): string[] { - const started: string[] = []; - const queuedMissions = db.all( - `${baseMissionSelect} - and m.archived_at is null - and m.status = 'queued' - order by - case m.priority - when 'urgent' then 0 - when 'high' then 1 - when 'normal' then 2 - else 3 - end, - m.created_at asc`, - [projectId] - ); - for (const row of queuedMissions) { - const mission = toMissionSummary(row); - const metadata = safeParseRecord(row.metadata_json ?? null); - const launch = metadata && isRecord(metadata.launch) ? metadata.launch : null; - if (launch && launch.autostart === false) continue; - const currentClaimToken = typeof row.queue_claim_token === "string" ? row.queue_claim_token.trim() : ""; - const currentClaimedAt = typeof row.queue_claimed_at === "string" ? row.queue_claimed_at.trim() : ""; - if (currentClaimToken.length > 0) { - const claimedAtMs = Date.parse(currentClaimedAt); - if (Number.isFinite(claimedAtMs) && Date.now() - claimedAtMs < MISSION_QUEUED_CLAIM_STALE_MS) { - continue; - } - clearMissionQueueClaim(mission.id, currentClaimToken); - } - const check = this.canStartMission(mission.id); - if (!check.allowed) break; - if (activeConcurrencyConfig.laneExclusivity && mission.laneId) { - const laneConflict = getMissionLaunchLaneConflict(mission.laneId, mission.id); - if (laneConflict) continue; - const laneClaim = this.isLaneClaimed(mission.laneId, mission.id); - if (laneClaim.claimed) continue; - } - const claimToken = claimQueuedMissionStart(mission.id); - if (!claimToken) { - continue; - } - recordEvent({ - missionId: mission.id, - eventType: "mission_ready_to_start", - actor: "system", - summary: "Mission eligible to start after concurrency slot opened.", - payload: { queuePosition: 1, claimToken } - }); - emit({ missionId: mission.id, reason: "ready_to_start", claimToken }); - started.push(mission.id); - } - return started; - }, - - getConcurrencyConfig(): MissionConcurrencyConfig { - return { ...activeConcurrencyConfig }; - }, - - setConcurrencyConfig(config: Partial): MissionConcurrencyConfig { - if (config.maxConcurrentMissions !== undefined) { - activeConcurrencyConfig.maxConcurrentMissions = Math.max(1, Math.floor(config.maxConcurrentMissions)); - } - if (config.laneExclusivity !== undefined) { - activeConcurrencyConfig.laneExclusivity = config.laneExclusivity; - } - return { ...activeConcurrencyConfig }; - } - }; - - serviceRef = service; - return service; -} diff --git a/apps/desktop/src/main/services/missions/phaseEngine.test.ts b/apps/desktop/src/main/services/missions/phaseEngine.test.ts deleted file mode 100644 index 2d669d83f..000000000 --- a/apps/desktop/src/main/services/missions/phaseEngine.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { PhaseCard, MissionPhaseConfiguration } from "../../../shared/types"; -import { - BUILT_IN_PHASE_KEYS, - createBuiltInPhaseCards, - createBuiltInPhaseProfiles, - ensurePlanningPhase, - validatePhaseSequence, - resolveFirstPostPlanningPhaseKey, - normalizeProfileInput, - applyPhaseCardsToPlanSteps, - groupMissionStepsByPhase, - selectMissionPhaseConfiguration, -} from "./phaseEngine"; - -function makePhaseCard(overrides: Partial = {}): PhaseCard { - return { - id: "phase-1", - phaseKey: "development", - name: "Development", - description: "Implement planned work.", - instructions: "Execute implementation tasks.", - model: { modelId: "openai/gpt-5.4", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-25T00:00:00.000Z", - updatedAt: "2026-03-25T00:00:00.000Z", - ...overrides, - }; -} - -describe("BUILT_IN_PHASE_KEYS", () => { - it("has the expected built-in keys", () => { - expect(BUILT_IN_PHASE_KEYS.planning).toBe("planning"); - expect(BUILT_IN_PHASE_KEYS.development).toBe("development"); - expect(BUILT_IN_PHASE_KEYS.integration).toBe("integration"); - expect(BUILT_IN_PHASE_KEYS.testing).toBe("testing"); - expect(BUILT_IN_PHASE_KEYS.validation).toBe("validation"); - expect(BUILT_IN_PHASE_KEYS.closeout).toBe("closeout"); - }); -}); - -describe("createBuiltInPhaseCards", () => { - it("creates 6 built-in phase cards", () => { - const cards = createBuiltInPhaseCards("2026-03-25T00:00:00.000Z"); - expect(cards).toHaveLength(6); - expect(cards.map((c) => c.phaseKey)).toEqual([ - "planning", - "development", - "integration", - "testing", - "validation", - "closeout", - ]); - }); - - it("sets planning phase as mustBeFirst and position 0", () => { - const cards = createBuiltInPhaseCards(); - const planning = cards.find((c) => c.phaseKey === "planning")!; - expect(planning.position).toBe(0); - expect(planning.orderingConstraints?.mustBeFirst).toBe(true); - expect(planning.requiresApproval).toBe(true); - expect(planning.askQuestions.enabled).toBe(true); - }); - - it("assigns sequential positions", () => { - const cards = createBuiltInPhaseCards(); - expect(cards.map((c) => c.position)).toEqual([0, 1, 2, 3, 4, 5]); - }); - - it("marks all as isBuiltIn and not isCustom", () => { - const cards = createBuiltInPhaseCards(); - for (const card of cards) { - expect(card.isBuiltIn).toBe(true); - expect(card.isCustom).toBe(false); - } - }); - - it("sets validation gates on testing and validation phases", () => { - const cards = createBuiltInPhaseCards(); - const testing = cards.find((c) => c.phaseKey === "testing")!; - const validation = cards.find((c) => c.phaseKey === "validation")!; - expect(testing.validationGate.tier).toBe("dedicated"); - expect(testing.validationGate.required).toBe(true); - expect(validation.validationGate.tier).toBe("dedicated"); - expect(validation.validationGate.required).toBe(true); - }); - - it("uses provided timestamp for createdAt and updatedAt", () => { - const ts = "2026-01-01T00:00:00.000Z"; - const cards = createBuiltInPhaseCards(ts); - for (const card of cards) { - expect(card.createdAt).toBe(ts); - expect(card.updatedAt).toBe(ts); - } - }); -}); - -describe("createBuiltInPhaseProfiles", () => { - it("creates default and TDD profiles", () => { - const cards = createBuiltInPhaseCards(); - const profiles = createBuiltInPhaseProfiles(cards); - expect(profiles).toHaveLength(2); - expect(profiles[0].name).toBe("Default"); - expect(profiles[1].name).toBe("TDD"); - }); - - it("default profile has Planning -> Development -> Integration -> Testing -> Validation -> Closeout order", () => { - const cards = createBuiltInPhaseCards(); - const profiles = createBuiltInPhaseProfiles(cards); - const defaultProfile = profiles.find((p) => p.name === "Default")!; - expect(defaultProfile.phases.map((p) => p.phaseKey)).toEqual([ - "planning", "development", "integration", "testing", "validation", "closeout", - ]); - }); - - it("TDD profile has Planning -> Testing -> Development -> Integration -> Validation -> Closeout order", () => { - const cards = createBuiltInPhaseCards(); - const profiles = createBuiltInPhaseProfiles(cards); - const tddProfile = profiles.find((p) => p.name === "TDD")!; - expect(tddProfile.phases.map((p) => p.phaseKey)).toEqual([ - "planning", "testing", "development", "integration", "validation", "closeout", - ]); - }); - - it("marks default profile as isDefault", () => { - const cards = createBuiltInPhaseCards(); - const profiles = createBuiltInPhaseProfiles(cards); - expect(profiles[0].isDefault).toBe(true); - expect(profiles[1].isDefault).toBe(false); - }); - - it("reassigns positions based on profile order", () => { - const cards = createBuiltInPhaseCards(); - const profiles = createBuiltInPhaseProfiles(cards); - const tdd = profiles.find((p) => p.name === "TDD")!; - expect(tdd.phases[0].position).toBe(0); // planning - expect(tdd.phases[1].position).toBe(1); // testing - expect(tdd.phases[2].position).toBe(2); // development - expect(tdd.phases[3].position).toBe(3); // integration - expect(tdd.phases[4].position).toBe(4); // validation - expect(tdd.phases[5].position).toBe(5); // closeout - }); -}); - -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(); - const errors = validatePhaseSequence(cards); - expect(errors).toEqual([]); - }); - - it("errors when no phases provided", () => { - const errors = validatePhaseSequence([]); - expect(errors).toContain("At least one phase is required."); - }); - - it("errors when development phase is missing", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ phaseKey: "planning", position: 0 }), - ]); - expect(errors).toContain("Development or implementation phase is required."); - }); - - it("accepts custom implementation phases as the development portion of a mission", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ id: "phase-planning", phaseKey: "planning", name: "Planning", position: 0 }), - makePhaseCard({ - id: "phase-implementation-math", - phaseKey: "implementation_math", - name: "Implementation math", - position: 1, - isBuiltIn: false, - isCustom: true, - }), - makePhaseCard({ - id: "phase-implementation-strings", - phaseKey: "implementation_strings", - name: "Implementation strings", - position: 2, - isBuiltIn: false, - isCustom: true, - }), - ]); - expect(errors).toEqual([]); - }); - - it("accepts arbitrary custom executable phases as the development portion of a mission", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ id: "phase-planning", phaseKey: "planning", name: "Planning", position: 0 }), - makePhaseCard({ - id: "phase-parser-enrichment", - phaseKey: "parser_enrichment", - name: "Parser enrichment", - position: 1, - isBuiltIn: false, - isCustom: true, - }), - makePhaseCard({ - id: "phase-formatter-reporting", - phaseKey: "formatter_reporting", - name: "Formatter reporting", - position: 2, - isBuiltIn: false, - isCustom: true, - }), - ]); - expect(errors).toEqual([]); - }); - - it("errors when planning appears after development", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ phaseKey: "development", position: 0 }), - makePhaseCard({ id: "phase-2", phaseKey: "planning", position: 1 }), - ]); - expect(errors).toContain("Planning phase must appear before development."); - }); - - it("errors on duplicate phase keys", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ phaseKey: "development", position: 0 }), - makePhaseCard({ id: "phase-dup", phaseKey: "development", position: 1 }), - ]); - expect(errors).toContain("Duplicate phase key: development."); - }); - - it("errors on empty phaseKey", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ phaseKey: "", position: 0 }), - makePhaseCard({ id: "phase-dev", phaseKey: "development", position: 1 }), - ]); - expect(errors).toContain("Phase at position 1 is missing phaseKey."); - }); - - it("allows valid TDD ordering (planning, testing, development, validation)", () => { - const cards = createBuiltInPhaseCards(); - const tddOrder = [ - cards.find((c) => c.phaseKey === "planning")!, - cards.find((c) => c.phaseKey === "testing")!, - cards.find((c) => c.phaseKey === "development")!, - cards.find((c) => c.phaseKey === "validation")!, - ]; - const errors = validatePhaseSequence(tddOrder); - expect(errors).toEqual([]); - }); - - it("enforces built-in ordering constraints", () => { - const cards = createBuiltInPhaseCards(); - const closeout = cards.find((c) => c.phaseKey === "closeout")!; - const development = cards.find((c) => c.phaseKey === "development")!; - const planning = cards.find((c) => c.phaseKey === "planning")!; - const validation = cards.find((c) => c.phaseKey === "validation")!; - - const errors = validatePhaseSequence([development, closeout, validation, planning]); - - expect(errors).toContain("Planning phase must be first."); - expect(errors).toContain("Closeout phase must be last."); - expect(errors).toContain("Closeout phase must come after Validation."); - }); - - it("errors when an ordering prerequisite is missing", () => { - const cards = createBuiltInPhaseCards(); - const development = cards.find((c) => c.phaseKey === "development")!; - const closeout = cards.find((c) => c.phaseKey === "closeout")!; - - const errors = validatePhaseSequence([development, closeout]); - - expect(errors).toContain("Closeout phase requires validation phase."); - }); - - it("deduplicates error messages", () => { - const errors = validatePhaseSequence([ - makePhaseCard({ phaseKey: "development", position: 0 }), - makePhaseCard({ id: "phase-2", phaseKey: "development", position: 1 }), - makePhaseCard({ id: "phase-3", phaseKey: "development", position: 2 }), - ]); - const dupeErrors = errors.filter((e) => e.includes("Duplicate")); - expect(dupeErrors).toHaveLength(1); - }); -}); - -describe("normalizeProfileInput", () => { - it("assigns sequential positions and timestamps", () => { - const now = "2026-03-25T12:00:00.000Z"; - const profile = normalizeProfileInput( - { - id: "custom-1", - name: " Custom Profile ", - description: " A custom profile ", - phases: [ - makePhaseCard({ phaseKey: "development", position: 99 }), - makePhaseCard({ id: "phase-2", phaseKey: "testing", position: 5 }), - ], - isDefault: true, - }, - now, - ); - expect(profile.name).toBe("Custom Profile"); - expect(profile.description).toBe("A custom profile"); - expect(profile.phases[0].position).toBe(0); - expect(profile.phases[1].position).toBe(1); - expect(profile.isBuiltIn).toBe(false); - expect(profile.isDefault).toBe(true); - expect(profile.createdAt).toBe(now); - expect(profile.updatedAt).toBe(now); - }); - - it("trims empty id to empty string", () => { - const profile = normalizeProfileInput({ - id: " ", - name: "Test", - phases: [makePhaseCard()], - }); - expect(profile.id).toBe(""); - }); - - it("defaults isDefault to false when not provided", () => { - const profile = normalizeProfileInput({ - name: "Test", - phases: [makePhaseCard()], - }); - expect(profile.isDefault).toBe(false); - }); -}); - -describe("applyPhaseCardsToPlanSteps", () => { - it("assigns phase metadata to plan steps based on kind/type inference", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Research", detail: "Research codebase", kind: "analysis", metadata: { stepType: "analysis" } }, - { index: 1, title: "Implement", detail: "Write code", kind: "implementation", metadata: { stepType: "code" } }, - { index: 2, title: "Test", detail: "Run tests", kind: "test", metadata: { stepType: "test" } }, - { index: 3, title: "Validate", detail: "Final validation", kind: "summary", metadata: { stepType: "review" } }, - ]; - - const applied = applyPhaseCardsToPlanSteps(steps, phases); - - expect(applied[0].metadata.phaseKey).toBe("planning"); - expect(applied[1].metadata.phaseKey).toBe("development"); - expect(applied[2].metadata.phaseKey).toBe("testing"); - expect(applied[3].metadata.phaseKey).toBe("validation"); - }); - - it("respects explicit phaseKey in metadata", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Custom", detail: "Custom step", kind: "misc", metadata: { phaseKey: "testing" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("testing"); - }); - - it("routes steps to custom phases when kind or stepType matches a custom phase key", () => { - const basePhases = createBuiltInPhaseCards(); - const developmentPhase = basePhases.find((phase) => phase.phaseKey === "development"); - if (!developmentPhase) throw new Error("Expected development phase"); - const phases = [ - ...basePhases, - { - ...developmentPhase, - id: "custom:review", - phaseKey: "review", - name: "Review", - position: basePhases.length, - isBuiltIn: false, - isCustom: true, - }, - { - ...developmentPhase, - id: "custom:slash-command", - phaseKey: "slash_command", - name: "Slash command", - position: basePhases.length + 1, - isBuiltIn: false, - isCustom: true, - }, - ]; - const steps = [ - { index: 0, title: "Review", detail: "Review work", kind: "task", metadata: { stepType: "review" } }, - { index: 1, title: "Slash", detail: "Run slash command", kind: "slash_command", metadata: {} }, - ]; - - const applied = applyPhaseCardsToPlanSteps(steps, phases); - - expect(applied[0].metadata.phaseKey).toBe("review"); - expect(applied[1].metadata.phaseKey).toBe("slash_command"); - }); - - it("attaches phase model, budget, instructions, and validation info", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Implement", detail: "Code", kind: "implementation", metadata: {} }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseName).toBe("Development"); - expect(applied[0].metadata.phaseModel).toBeDefined(); - expect(applied[0].metadata.phaseInstructions).toBeDefined(); - expect(applied[0].metadata.phaseValidation).toBeDefined(); - expect(applied[0].metadata.executorKind).toBeDefined(); - }); - - it("falls back to development phase for unknown step types", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Unknown", detail: "Unknown step", kind: "unknown", metadata: { stepType: "unknown" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("development"); - }); - - it("falls back to the first custom implementation phase for unknown step types when development is custom-split", () => { - const phases = [ - makePhaseCard({ id: "phase-planning", phaseKey: "planning", name: "Planning", position: 0 }), - makePhaseCard({ - id: "phase-implementation-math", - phaseKey: "implementation_math", - name: "Implementation math", - position: 1, - isBuiltIn: false, - isCustom: true, - }), - makePhaseCard({ - id: "phase-implementation-strings", - phaseKey: "implementation_strings", - name: "Implementation strings", - position: 2, - isBuiltIn: false, - isCustom: true, - }), - ]; - const steps = [ - { index: 0, title: "Unknown", detail: "Unknown step", kind: "unknown", metadata: { stepType: "unknown" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("implementation_math"); - }); - - it("infers milestone stepType as validation", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Milestone", detail: "Check milestone", kind: "task", metadata: { stepType: "milestone" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("validation"); - }); - - it("infers validation kind as validation even without a stepType", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Validate", detail: "Check output", kind: "validation", metadata: {} }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("validation"); - }); - - it("infers test kind as testing even without a stepType", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Test", detail: "Run tests", kind: "test", metadata: {} }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("testing"); - }); - - it("infers integration stepType as integration when the integration phase exists", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Merge", detail: "Integration step", kind: "integration", metadata: { stepType: "integration" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("integration"); - }); - - it("falls back to validation for integration steps when no integration phase exists", () => { - const phases = createBuiltInPhaseCards().filter((phase) => phase.phaseKey !== "integration"); - const steps = [ - { index: 0, title: "Merge", detail: "Integration step", kind: "integration", metadata: { stepType: "integration" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("validation"); - }); - - it("infers closeout stepType as closeout when the closeout phase exists", () => { - const phases = createBuiltInPhaseCards(); - const steps = [ - { index: 0, title: "Summarize", detail: "Closeout step", kind: "summary", metadata: { stepType: "closeout" } }, - ]; - const applied = applyPhaseCardsToPlanSteps(steps, phases); - expect(applied[0].metadata.phaseKey).toBe("closeout"); - }); -}); - -describe("groupMissionStepsByPhase", () => { - it("groups steps by phaseKey from metadata", () => { - const steps = [ - { metadata: { phaseKey: "planning", phaseName: "Planning" }, status: "succeeded" }, - { metadata: { phaseKey: "development", phaseName: "Development" }, status: "succeeded" }, - { metadata: { phaseKey: "development", phaseName: "Development" }, status: "in_progress" }, - { metadata: { phaseKey: "testing", phaseName: "Testing" }, status: "succeeded" }, - ]; - const groups = groupMissionStepsByPhase(steps); - expect(groups).toEqual([ - { key: "planning", name: "Planning", total: 1, completed: 1 }, - { key: "development", name: "Development", total: 2, completed: 1 }, - { key: "testing", name: "Testing", total: 1, completed: 1 }, - ]); - }); - - it("defaults to development phase when metadata is missing", () => { - const steps = [ - { metadata: null, status: "succeeded" }, - { metadata: {}, status: "in_progress" }, - ]; - const groups = groupMissionStepsByPhase(steps); - expect(groups).toEqual([ - { key: "development", name: "Development", total: 2, completed: 1 }, - ]); - }); - - it("counts skipped and superseded as completed", () => { - const steps = [ - { metadata: { phaseKey: "development", phaseName: "Development" }, status: "skipped" }, - { metadata: { phaseKey: "development", phaseName: "Development" }, status: "superseded" }, - { metadata: { phaseKey: "development", phaseName: "Development" }, status: "done" }, - ]; - const groups = groupMissionStepsByPhase(steps); - expect(groups[0].completed).toBe(3); - expect(groups[0].total).toBe(3); - }); - - it("does not count failed or pending as completed", () => { - const steps = [ - { metadata: { phaseKey: "testing", phaseName: "Testing" }, status: "failed" }, - { metadata: { phaseKey: "testing", phaseName: "Testing" }, status: "pending" }, - ]; - const groups = groupMissionStepsByPhase(steps); - expect(groups[0].completed).toBe(0); - expect(groups[0].total).toBe(2); - }); -}); - -describe("selectMissionPhaseConfiguration", () => { - const phases = createBuiltInPhaseCards(); - - it("returns override phases when present", () => { - const config: MissionPhaseConfiguration = { - profile: { phases: [phases[0]] } as any, - override: { phases } as any, - selectedPhases: [phases[0]], - }; - expect(selectMissionPhaseConfiguration(config)).toEqual(phases); - }); - - it("falls back to profile phases when no override", () => { - const config: MissionPhaseConfiguration = { - profile: { phases: [phases[0], phases[1]] } as any, - override: null, - selectedPhases: phases, - }; - expect(selectMissionPhaseConfiguration(config)).toEqual([phases[0], phases[1]]); - }); - - it("falls back to selectedPhases when no override or profile", () => { - const config = { - override: null, - selectedPhases: phases, - } as MissionPhaseConfiguration; - expect(selectMissionPhaseConfiguration(config)).toEqual(phases); - }); - - it("returns empty array for null config", () => { - expect(selectMissionPhaseConfiguration(null)).toEqual([]); - }); - - it("returns empty array for undefined config", () => { - expect(selectMissionPhaseConfiguration(undefined)).toEqual([]); - }); - - it("falls through override with empty phases array", () => { - const config: MissionPhaseConfiguration = { - override: { phases: [] } as any, - profile: { phases } as any, - selectedPhases: [], - }; - expect(selectMissionPhaseConfiguration(config)).toEqual(phases); - }); -}); diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts deleted file mode 100644 index 5d8b6c4c0..000000000 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ /dev/null @@ -1,522 +0,0 @@ -import type { - MissionStep, - MissionStepMetadata, - PhaseCard, - PhaseProfile, - SavePhaseProfileArgs, - ModelConfig, - MissionPhaseConfiguration, -} from "../../../shared/types"; -import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; -type MissionPlanStepDraft = { - index: number; - title: string; - detail: string; - kind: string; - metadata: Record; -}; -import { phaseModelToExecutorKind } from "../orchestrator/executionPolicy"; -import { nowIso } from "../shared/utils"; - -export const BUILT_IN_PHASE_KEYS = { - planning: "planning", - development: "development", - integration: "integration", - testing: "testing", - validation: "validation", - closeout: "closeout", - /** @deprecated Legacy phase key retained only for backward compatibility with existing mission metadata. */ - prAndConflicts: "pr_conflict_resolution", -} as const; - -function normalizePhaseKey(value: string | null | undefined): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -export function isDevelopmentLikePhaseKey(phaseKey: string | null | undefined): boolean { - const key = normalizePhaseKey(phaseKey); - if (!key) return false; - if (key === BUILT_IN_PHASE_KEYS.development || key === "implementation" || key === "code") return true; - return ( - key.startsWith("development_") - || key.startsWith("implementation_") - || key.endsWith("_development") - || key.endsWith("_implementation") - ); -} - -function isCustomExecutablePhase(phase: PhaseCard): boolean { - const key = normalizePhaseKey(phase.phaseKey); - if (!key || phase.isBuiltIn === true || phase.isCustom !== true) return false; - return !Object.values(BUILT_IN_PHASE_KEYS).includes(key as (typeof BUILT_IN_PHASE_KEYS)[keyof typeof BUILT_IN_PHASE_KEYS]); -} - -function isDevelopmentLikePhase(phase: PhaseCard): boolean { - return isDevelopmentLikePhaseKey(phase.phaseKey) || isCustomExecutablePhase(phase); -} - -export function resolveDevelopmentPhaseKey(phases: PhaseCard[]): string { - const phase = phases.find((entry) => isDevelopmentLikePhase(entry)); - 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, - }; - }); -} - -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"; - -const DEFAULT_MODELS: Record = { - [BUILT_IN_PHASE_KEYS.planning]: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, - [BUILT_IN_PHASE_KEYS.development]: { modelId: DEFAULT_CODEX_PHASE_MODEL_ID, thinkingLevel: "medium" }, - [BUILT_IN_PHASE_KEYS.integration]: { modelId: DEFAULT_CODEX_PHASE_MODEL_ID, thinkingLevel: "medium" }, - [BUILT_IN_PHASE_KEYS.testing]: { modelId: DEFAULT_CODEX_PHASE_MODEL_ID, thinkingLevel: "low" }, - [BUILT_IN_PHASE_KEYS.validation]: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, - [BUILT_IN_PHASE_KEYS.closeout]: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, -}; - -export function createBuiltInPhaseCards(at: string = nowIso()): PhaseCard[] { - return [ - { - id: `builtin:${BUILT_IN_PHASE_KEYS.planning}`, - phaseKey: BUILT_IN_PHASE_KEYS.planning, - name: "Planning", - description: "Research, clarify requirements, and design the execution DAG.", - instructions: - "Investigate the codebase, identify dependencies/risks, and produce a concrete execution plan before implementation.", - model: DEFAULT_MODELS[BUILT_IN_PHASE_KEYS.planning], - budget: {}, - orderingConstraints: { - mustBeFirst: true, - }, - askQuestions: { - enabled: true, - maxQuestions: null, - }, - validationGate: { - tier: "none", - required: false, - }, - requiresApproval: true, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: at, - updatedAt: at, - }, - { - id: `builtin:${BUILT_IN_PHASE_KEYS.development}`, - phaseKey: BUILT_IN_PHASE_KEYS.development, - name: "Development", - description: "Implement planned work with lane-scoped workers.", - instructions: "Execute implementation tasks, update code, and publish structured progress/results.", - model: DEFAULT_MODELS[BUILT_IN_PHASE_KEYS.development], - budget: {}, - orderingConstraints: {}, - askQuestions: { - enabled: false, - }, - validationGate: { - tier: "none", - required: false, - }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: at, - updatedAt: at, - }, - { - id: `builtin:${BUILT_IN_PHASE_KEYS.integration}`, - phaseKey: BUILT_IN_PHASE_KEYS.integration, - name: "Integration", - description: "Join child-lane work into the mission result lane.", - instructions: - "After implementation workers finish, consolidate their outputs, resolve conflicts, and assemble a single reviewable result lane.", - model: DEFAULT_MODELS[BUILT_IN_PHASE_KEYS.integration], - budget: {}, - orderingConstraints: { - mustFollow: [BUILT_IN_PHASE_KEYS.development], - }, - askQuestions: { - enabled: false, - }, - validationGate: { - tier: "self", - required: true, - criteria: "All child-lane outputs are accounted for and the result lane can be inspected.", - evidenceRequirements: ["changed_files_summary", "risk_notes"], - capabilityFallback: "warn", - }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: at, - updatedAt: at, - }, - { - id: `builtin:${BUILT_IN_PHASE_KEYS.testing}`, - phaseKey: BUILT_IN_PHASE_KEYS.testing, - name: "Testing", - description: "Execute and stabilize test suites.", - instructions: "Run tests, collect failures, and feed remediation details back into execution.", - model: DEFAULT_MODELS[BUILT_IN_PHASE_KEYS.testing], - budget: {}, - orderingConstraints: {}, - askQuestions: { - enabled: false, - }, - validationGate: { - tier: "dedicated", - required: true, - }, - isBuiltIn: true, - isCustom: false, - position: 3, - createdAt: at, - updatedAt: at, - }, - { - id: `builtin:${BUILT_IN_PHASE_KEYS.validation}`, - phaseKey: BUILT_IN_PHASE_KEYS.validation, - name: "Validation", - description: "Cross-check mission output against requested outcomes.", - instructions: "Validate done criteria, audit risk, and identify remaining obligations before completion.", - model: DEFAULT_MODELS[BUILT_IN_PHASE_KEYS.validation], - budget: {}, - orderingConstraints: {}, - askQuestions: { - enabled: false, - }, - validationGate: { - tier: "dedicated", - required: true, - }, - isBuiltIn: true, - isCustom: false, - position: 4, - createdAt: at, - updatedAt: at, - }, - { - id: `builtin:${BUILT_IN_PHASE_KEYS.closeout}`, - phaseKey: BUILT_IN_PHASE_KEYS.closeout, - name: "Closeout", - description: "Collect final proof and present the finished mission output.", - instructions: - "Confirm the result lane, summarize the final product, attach required proof, and leave the mission ready for user review.", - model: DEFAULT_MODELS[BUILT_IN_PHASE_KEYS.closeout], - budget: {}, - orderingConstraints: { - mustBeLast: true, - mustFollow: [BUILT_IN_PHASE_KEYS.validation], - }, - askQuestions: { - enabled: false, - }, - validationGate: { - tier: "self", - required: true, - criteria: "Outcome summary, changed-files summary, and remaining risks are visible before completion.", - evidenceRequirements: ["final_outcome_summary", "changed_files_summary", "risk_notes"], - capabilityFallback: "warn", - }, - isBuiltIn: true, - isCustom: false, - position: 5, - createdAt: at, - updatedAt: at, - }, - ]; -} - -export function createBuiltInPhaseProfiles(cards: PhaseCard[], at: string = nowIso()): PhaseProfile[] { - const byKey = new Map(cards.map((card) => [card.phaseKey, card] as const)); - const defaultKeys = [ - BUILT_IN_PHASE_KEYS.planning, - BUILT_IN_PHASE_KEYS.development, - BUILT_IN_PHASE_KEYS.integration, - BUILT_IN_PHASE_KEYS.testing, - BUILT_IN_PHASE_KEYS.validation, - BUILT_IN_PHASE_KEYS.closeout, - ]; - const tddKeys = [ - BUILT_IN_PHASE_KEYS.planning, - BUILT_IN_PHASE_KEYS.testing, - BUILT_IN_PHASE_KEYS.development, - BUILT_IN_PHASE_KEYS.integration, - BUILT_IN_PHASE_KEYS.validation, - BUILT_IN_PHASE_KEYS.closeout, - ]; - const asPhaseList = (keys: string[]): PhaseCard[] => - keys - .map((key, index) => { - const card = byKey.get(key); - if (!card) return null; - return { ...card, position: index }; - }) - .filter((card): card is PhaseCard => card != null); - - return [ - { - id: "builtin:default", - name: "Default", - description: "Planning -> Development -> Integration -> Testing -> Validation -> Closeout", - phases: asPhaseList(defaultKeys), - isBuiltIn: true, - isDefault: true, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:tdd", - name: "TDD", - description: "Planning -> Testing -> Development -> Integration -> Validation -> Closeout", - phases: asPhaseList(tddKeys), - isBuiltIn: true, - isDefault: false, - createdAt: at, - updatedAt: at, - }, - ]; -} - -export function validatePhaseSequence(phases: PhaseCard[]): string[] { - const errors: string[] = []; - if (!phases.length) { - errors.push("At least one phase is required."); - return errors; - } - - const byKey = new Map(); - const labelForKey = (phaseKey: string): string => { - const phase = phases.find((entry) => normalizePhaseKey(entry.phaseKey) === normalizePhaseKey(phaseKey)); - return phase?.name?.trim() || phaseKey; - }; - let firstDevelopmentIndex = -1; - let firstPlanningIndex = -1; - for (let i = 0; i < phases.length; i += 1) { - const phase = phases[i]!; - const phaseKey = normalizePhaseKey(phase.phaseKey); - if (!phaseKey) { - errors.push(`Phase at position ${i + 1} is missing phaseKey.`); - continue; - } - if (byKey.has(phaseKey)) { - errors.push(`Duplicate phase key: ${phase.phaseKey}.`); - } - byKey.set(phaseKey, i); - if (isDevelopmentLikePhase(phase) && firstDevelopmentIndex < 0) firstDevelopmentIndex = i; - if (phaseKey === BUILT_IN_PHASE_KEYS.planning && firstPlanningIndex < 0) firstPlanningIndex = i; - } - - phases.forEach((phase, index) => { - const phaseKey = normalizePhaseKey(phase.phaseKey); - if (!phaseKey) return; - const label = phase.name.trim() || phase.phaseKey; - const orderingConstraints = phase.orderingConstraints ?? {}; - if (orderingConstraints.mustBeFirst && index !== 0) { - errors.push(`${label} phase must be first.`); - } - if (orderingConstraints.mustBeLast && index !== phases.length - 1) { - errors.push(`${label} phase must be last.`); - } - for (const requiredPredecessor of orderingConstraints.mustFollow ?? []) { - const predecessorKey = normalizePhaseKey(requiredPredecessor); - const predecessorIndex = byKey.get(predecessorKey); - if (predecessorIndex == null) { - errors.push(`${label} phase requires ${labelForKey(predecessorKey)} phase.`); - } else if (predecessorIndex >= index) { - errors.push(`${label} phase must come after ${labelForKey(predecessorKey)}.`); - } - } - for (const requiredSuccessor of orderingConstraints.mustPrecede ?? []) { - const successorKey = normalizePhaseKey(requiredSuccessor); - const successorIndex = byKey.get(successorKey); - if (successorIndex == null) { - errors.push(`${label} phase requires ${labelForKey(successorKey)} phase.`); - } else if (successorIndex <= index) { - errors.push(`${label} phase must come before ${labelForKey(successorKey)}.`); - } - } - }); - - if (firstDevelopmentIndex < 0) { - errors.push("Development or implementation phase is required."); - } - if ( - firstPlanningIndex >= 0 - && firstDevelopmentIndex >= 0 - && firstPlanningIndex > firstDevelopmentIndex - ) { - errors.push("Planning phase must appear before development."); - } - - return [...new Set(errors)]; -} - -export function normalizeProfileInput(input: SavePhaseProfileArgs["profile"], now: string = nowIso()): PhaseProfile { - const phases = input.phases - .map((phase, index) => ({ - ...phase, - position: index, - updatedAt: now, - createdAt: phase.createdAt ?? now, - })) - .sort((a, b) => a.position - b.position) - .map((phase, index) => ({ ...phase, position: index })); - return { - id: input.id?.trim() || "", - name: input.name.trim(), - description: input.description?.trim() || "", - phases, - isBuiltIn: false, - isDefault: input.isDefault === true, - createdAt: now, - updatedAt: now, - }; -} - -function inferPhaseKeyFromStep(kind: string, metadata: MissionStepMetadata | Record, phases: PhaseCard[]): string { - const explicit = typeof metadata.phaseKey === "string" ? metadata.phaseKey.trim() : ""; - if (explicit.length && phases.some((phase) => phase.phaseKey === explicit)) { - return explicit; - } - const stepType = typeof metadata.stepType === "string" ? metadata.stepType.trim() : ""; - const lowerKind = kind.trim().toLowerCase(); - const lowerType = stepType.toLowerCase(); - const phaseByLowerKey = new Map(phases.map((phase) => [phase.phaseKey.trim().toLowerCase(), phase.phaseKey] as const)); - const directTypePhase = lowerType ? phaseByLowerKey.get(lowerType) : undefined; - if (directTypePhase) return directTypePhase; - const directKindPhase = lowerKind ? phaseByLowerKey.get(lowerKind) : undefined; - if (directKindPhase) return directKindPhase; - - if (lowerType === "analysis" || lowerType === "planning") { - return phases.some((phase) => phase.phaseKey === BUILT_IN_PHASE_KEYS.planning) - ? BUILT_IN_PHASE_KEYS.planning - : resolveDevelopmentPhaseKey(phases); - } - if (lowerType === "test" || lowerKind === "test" || lowerKind === "testing") return BUILT_IN_PHASE_KEYS.testing; - if (lowerType === "milestone" || lowerType === "validation" || lowerKind === "validation") return BUILT_IN_PHASE_KEYS.validation; - if (lowerType === "review") return BUILT_IN_PHASE_KEYS.validation; - if (lowerType === "integration" || lowerType === "merge" || lowerKind === "integration") { - return phases.some((phase) => phase.phaseKey === BUILT_IN_PHASE_KEYS.integration) - ? BUILT_IN_PHASE_KEYS.integration - : BUILT_IN_PHASE_KEYS.validation; - } - if (lowerKind === "summary" || lowerType === "closeout" || lowerKind === "closeout") { - return phases.some((phase) => phase.phaseKey === BUILT_IN_PHASE_KEYS.closeout) - ? BUILT_IN_PHASE_KEYS.closeout - : BUILT_IN_PHASE_KEYS.validation; - } - return resolveDevelopmentPhaseKey(phases); -} - -export function applyPhaseCardsToPlanSteps(steps: MissionPlanStepDraft[], phases: PhaseCard[]): MissionPlanStepDraft[] { - const byKey = new Map(phases.map((phase) => [phase.phaseKey, phase] as const)); - return steps.map((step) => { - const metadata = { ...(step.metadata ?? {}) }; - const phaseKey = inferPhaseKeyFromStep(step.kind, metadata, phases); - const phase = byKey.get(phaseKey) ?? phases[0] ?? null; - if (!phase) return step; - - const executorKind = phaseModelToExecutorKind(phase.model.modelId); - return { - ...step, - metadata: { - ...metadata, - phaseCardId: phase.id, - phaseKey: phase.phaseKey, - phaseName: phase.name, - phasePosition: phase.position, - phaseModel: phase.model, - phaseBudget: phase.budget, - phaseValidation: phase.validationGate, - phaseInstructions: phase.instructions, - executorKind, - }, - }; - }); -} - -export function groupMissionStepsByPhase(steps: Array | null; status?: string }>): Array<{ - key: string; - name: string; - total: number; - completed: number; -}> { - const map = new Map(); - for (const step of steps) { - const metadata = (step.metadata && typeof step.metadata === "object" ? step.metadata : {}) as Record; - const key = typeof metadata.phaseKey === "string" && metadata.phaseKey.trim().length > 0 - ? metadata.phaseKey - : BUILT_IN_PHASE_KEYS.development; - const name = typeof metadata.phaseName === "string" && metadata.phaseName.trim().length > 0 - ? metadata.phaseName - : "Development"; - const entry = map.get(key) ?? { key, name, total: 0, completed: 0 }; - entry.total += 1; - const status = typeof (step as { status?: string }).status === "string" ? (step as { status?: string }).status : ""; - if (status === "succeeded" || status === "skipped" || status === "superseded" || status === "done") { - entry.completed += 1; - } - map.set(key, entry); - } - return Array.from(map.values()); -} - -export function selectMissionPhaseConfiguration(config: MissionPhaseConfiguration | null | undefined): PhaseCard[] { - if (!config) return []; - if (config.override?.phases?.length) return config.override.phases; - if (config.profile?.phases?.length) return config.profile.phases; - return config.selectedPhases ?? []; -} diff --git a/apps/desktop/src/main/services/notifications/notificationEventBus.ts b/apps/desktop/src/main/services/notifications/notificationEventBus.ts index 3f285208a..d8e690b3a 100644 --- a/apps/desktop/src/main/services/notifications/notificationEventBus.ts +++ b/apps/desktop/src/main/services/notifications/notificationEventBus.ts @@ -22,11 +22,9 @@ import { buildApnsPayload, isAllowedByPrefs, mapChatEvent, - mapMissionEvent, mapPrEvent, mapSystemEvent, type MappedNotification, - type MissionPhaseEvent, type SystemEvent, } from "./notificationMapper"; import type { ApnsEnvelope, ApnsService } from "./apnsService"; @@ -205,7 +203,7 @@ export function createNotificationEventBus(args: NotificationEventBusArgs) { if (!args.apnsService || !args.apnsService.isConfigured()) continue; // `apns-expiration` drops the push if it can't be delivered within the - // window. For priority-5 / passive pushes (turn completed, mission phase, + // window. For priority-5 / passive pushes (turn completed, // sub-agent started) we don't want APNs queueing a stale banner if the // device is offline for hours — 10 minutes is plenty for them to still // feel "live". For priority-10 attention pushes (awaiting input, CI @@ -285,10 +283,6 @@ export function createNotificationEventBus(args: NotificationEventBusArgs) { const mapped = mapPrEvent(event); if (mapped.length > 0) fanOut(mapped); }, - publishMissionEvent(event: MissionPhaseEvent): void { - const mapped = mapMissionEvent(event); - if (mapped.length > 0) fanOut(mapped); - }, publishSystemEvent(event: SystemEvent): void { const mapped = mapSystemEvent(event); if (mapped.length > 0) fanOut(mapped); diff --git a/apps/desktop/src/main/services/notifications/notificationMapper.test.ts b/apps/desktop/src/main/services/notifications/notificationMapper.test.ts index 28f9c2010..16eb36cd8 100644 --- a/apps/desktop/src/main/services/notifications/notificationMapper.test.ts +++ b/apps/desktop/src/main/services/notifications/notificationMapper.test.ts @@ -3,7 +3,6 @@ import { buildApnsPayload, isAllowedByPrefs, mapChatEvent, - mapMissionEvent, mapPrEvent, mapSystemEvent, type MappedNotification, @@ -131,13 +130,7 @@ describe("mapPrEvent", () => { }); }); -describe("mapMissionEvent + mapSystemEvent", () => { - it("maps mission phase change to CTO category", () => { - const [mapped] = mapMissionEvent({ missionId: "m-1", phase: "development", message: "Entered development" }); - expect(mapped.category).toBe("CTO_MISSION_PHASE"); - expect(mapped.deepLink).toBe("ade://mission/m-1"); - }); - +describe("mapSystemEvent", () => { it("maps system auth_rate_limit to priority-10 alert", () => { const [mapped] = mapSystemEvent({ kind: "auth_rate_limit", diff --git a/apps/desktop/src/main/services/notifications/notificationMapper.ts b/apps/desktop/src/main/services/notifications/notificationMapper.ts index 45a420ca7..aaa0b9d1e 100644 --- a/apps/desktop/src/main/services/notifications/notificationMapper.ts +++ b/apps/desktop/src/main/services/notifications/notificationMapper.ts @@ -20,7 +20,6 @@ export type NotificationCategory = | "CHAT_COMPLETED" | "CTO_SUBAGENT_STARTED" | "CTO_SUBAGENT_FINISHED" - | "CTO_MISSION_PHASE" | "PR_CI_FAILING" | "PR_REVIEW_REQUESTED" | "PR_CHANGES_REQUESTED" @@ -35,7 +34,6 @@ export type IosNotificationCategory = | "CHAT_TURN_COMPLETED" | "CTO_SUBAGENT_STARTED" | "CTO_SUBAGENT_FINISHED" - | "CTO_MISSION_PHASE" | "PR_CI_FAILING" | "PR_REVIEW_REQUESTED" | "PR_CHANGES_REQUESTED" @@ -264,14 +262,14 @@ export function mapChatEvent(envelope: AgentChatEventEnvelope): MappedNotificati case "delegation_state": { return [ { - category: "CTO_MISSION_PHASE", + category: "CTO_SUBAGENT_FINISHED", family: "cto", - title: "Mission phase changed", - body: truncate(event.message ?? "Mission phase advanced.", 178), + title: "Delegation updated", + body: truncate(event.message ?? "Delegation state changed.", 178), pushType: "alert", priority: 5, interruptionLevel: "passive", - collapseId: `mission:${sessionId}:phase`, + collapseId: `cto:${sessionId}:delegation`, deepLink, metadata: sessionMeta, }, @@ -382,29 +380,6 @@ export function mapPrEvent(args: { } } -export type MissionPhaseEvent = { - missionId: string; - phase: string; - message?: string; -}; - -export function mapMissionEvent(event: MissionPhaseEvent): MappedNotification[] { - return [ - { - category: "CTO_MISSION_PHASE", - family: "cto", - title: "Mission phase changed", - body: truncate(event.message ?? `Mission ${event.missionId} entered ${event.phase}.`, 178), - pushType: "alert", - priority: 5, - interruptionLevel: "passive", - collapseId: `mission:${event.missionId}:phase`, - deepLink: `ade://mission/${event.missionId}`, - metadata: { missionId: event.missionId, phase: event.phase }, - }, - ]; -} - export type SystemEvent = { kind: "provider_outage" | "auth_rate_limit" | "hook_failure"; title: string; @@ -543,7 +518,7 @@ export function isAllowedByPrefs( prefs: { enabled: boolean; chat: { awaitingInput: boolean; chatFailed: boolean; turnCompleted: boolean }; - cto: { subagentStarted: boolean; subagentFinished: boolean; missionPhaseChanged: boolean }; + cto: { subagentStarted: boolean; subagentFinished: boolean }; prs: { ciFailing: boolean; reviewRequested: boolean; changesRequested: boolean; mergeReady: boolean }; system: { providerOutage: boolean; authRateLimit: boolean; hookFailure: boolean }; muteUntil?: string | null; @@ -578,8 +553,6 @@ export function isAllowedByPrefs( return prefs.cto.subagentStarted; case "CTO_SUBAGENT_FINISHED": return prefs.cto.subagentFinished; - case "CTO_MISSION_PHASE": - return prefs.cto.missionPhaseChanged; case "PR_CI_FAILING": return prefs.prs.ciFailing; case "PR_REVIEW_REQUESTED": diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index a28febebb..296715b77 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -42,9 +42,9 @@ import { export type OpenCodeAgentProfile = "ade-plan" | "ade-edit" | "ade-full-auto" | "ade-helper"; const ADE_PLAN_TOOL_SELECTION: Record = { - // ADE mission planning must go through coordinator tools such as - // spawn_worker. The native `task` subagent tool is intentionally allowed so - // OpenCode child sessions surface in the desktop / TUI subagents panes. + // ADE planning uses coordinator tools such as spawn_worker. The native + // `task` subagent tool is intentionally allowed so OpenCode child sessions + // surface in the desktop / TUI subagents panes. codesearch: false, code_search: false, filesearch: false, diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts index 43aa2070d..8c080fc61 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -1095,7 +1095,7 @@ function enforceDedicatedBudget( const candidate = pickDedicatedEvictionCandidate(excludeKey); if (!candidate) { throw new Error( - `OpenCode runtime limit reached (${MAX_DEDICATED_OPENCODE_SERVERS} dedicated servers). Close or wait for an idle chat/mission runtime before starting another OpenCode session.`, + `OpenCode runtime limit reached (${MAX_DEDICATED_OPENCODE_SERVERS} dedicated servers). Close or wait for an idle chat runtime before starting another OpenCode session.`, ); } candidate.onEvict?.("budget_eviction"); diff --git a/apps/desktop/src/main/services/orchestrator/adaptiveRuntime.test.ts b/apps/desktop/src/main/services/orchestrator/adaptiveRuntime.test.ts deleted file mode 100644 index f9b52e746..000000000 --- a/apps/desktop/src/main/services/orchestrator/adaptiveRuntime.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - classifyTaskComplexity, - scaleParallelismCap, - evaluateModelDowngrade, -} from "./adaptiveRuntime"; -import type { TeamComplexityAssessment } from "../../../shared/types"; - -// --------------------------------------------------------------------------- -// VAL-ENH-001: Task complexity classifier exists -// --------------------------------------------------------------------------- -describe("classifyTaskComplexity", () => { - it("returns trivial for typo fix", () => { - expect(classifyTaskComplexity("fix typo in readme")).toBe("trivial"); - }); - - it("returns simple for a bug fix", () => { - expect(classifyTaskComplexity("fix bug in login form validation")).toBe("simple"); - }); - - it("returns moderate for a feature", () => { - expect(classifyTaskComplexity("integrate OAuth2 authentication service with user management module and add API endpoint for token refresh")).toBe("moderate"); - }); - - it("returns complex for a large overhaul", () => { - expect(classifyTaskComplexity( - "overhaul the entire architecture of the distributed microservice system including database migration, " + - "API redesign, and cross-cutting concerns across multiple teams with parallel deployment strategy" - )).toBe("complex"); - }); - - it("returns trivial for empty string", () => { - expect(classifyTaskComplexity("")).toBe("trivial"); - }); - - it("returns simple for short description without keywords", () => { - expect(classifyTaskComplexity("add a new button to the header")).toBe("simple"); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-ENH-003: TeamComplexityAssessment drives parallelism -// --------------------------------------------------------------------------- -describe("scaleParallelismCap", () => { - it("small scope gets ≤ 2", () => { - expect(scaleParallelismCap("small")).toBeLessThanOrEqual(2); - }); - - it("medium scope gets 2", () => { - expect(scaleParallelismCap("medium")).toBe(2); - }); - - it("large scope gets ≥ 4", () => { - expect(scaleParallelismCap("large")).toBeGreaterThanOrEqual(4); - }); - - it("very_large scope gets ≥ 4", () => { - expect(scaleParallelismCap("very_large")).toBeGreaterThanOrEqual(4); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-USAGE-003: Model downgrade at usage threshold -// --------------------------------------------------------------------------- -describe("evaluateModelDowngrade", () => { - it("does not downgrade when usage is below threshold", () => { - const result = evaluateModelDowngrade({ - currentModelId: "anthropic/claude-sonnet-4-6", - downgradeThresholdPct: 70, - currentUsagePct: 50, - }); - expect(result.downgraded).toBe(false); - expect(result.resolvedModelId).toBe("anthropic/claude-sonnet-4-6"); - }); - - it("downgrades when usage exceeds threshold", () => { - const result = evaluateModelDowngrade({ - currentModelId: "anthropic/claude-sonnet-4-6", - downgradeThresholdPct: 70, - currentUsagePct: 80, - }); - expect(result.downgraded).toBe(true); - expect(result.resolvedModelId).not.toBe("anthropic/claude-sonnet-4-6"); - expect(result.reason).toContain("80%"); - }); - - it("does not downgrade when threshold is null", () => { - const result = evaluateModelDowngrade({ - currentModelId: "anthropic/claude-sonnet-4-6", - downgradeThresholdPct: null, - currentUsagePct: 90, - }); - expect(result.downgraded).toBe(false); - }); - - it("uses explicit cheaper model when provided", () => { - const result = evaluateModelDowngrade({ - currentModelId: "anthropic/claude-sonnet-4-6", - downgradeThresholdPct: 70, - currentUsagePct: 80, - cheaperModelId: "anthropic/claude-haiku-3-5", - }); - expect(result.downgraded).toBe(true); - expect(result.resolvedModelId).toBe("anthropic/claude-haiku-3-5"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/adaptiveRuntime.ts b/apps/desktop/src/main/services/orchestrator/adaptiveRuntime.ts deleted file mode 100644 index 79817b94e..000000000 --- a/apps/desktop/src/main/services/orchestrator/adaptiveRuntime.ts +++ /dev/null @@ -1,172 +0,0 @@ -// --------------------------------------------------------------------------- -// Adaptive Runtime — classifyTaskComplexity, parallelism scaling, model downgrade -// --------------------------------------------------------------------------- - -import type { - TeamComplexityAssessment, -} from "../../../shared/types"; - -// --------------------------------------------------------------------------- -// Task Complexity Classification -// --------------------------------------------------------------------------- - -export type TaskComplexity = "trivial" | "simple" | "moderate" | "complex"; - -/** - * Classifies a task description into a complexity bucket based on heuristics. - * Used to scale parallelism caps and budget allocation. - * - * VAL-ENH-001: classifyTaskComplexity returns correct complexity for representative inputs. - */ -export function classifyTaskComplexity(description: string): TaskComplexity { - const text = (description ?? "").trim().toLowerCase(); - if (!text.length) return "trivial"; - - // Word count as primary signal - const words = text.split(/\s+/).filter(Boolean); - const wordCount = words.length; - - // Complexity indicators - const complexIndicators = [ - "parallel", "concurrent", "multi-service", "microservice", "distributed", - "migration", "refactor entire", "rewrite", "overhaul", "architecture", - "cross-cutting", "end-to-end", "full-stack", "multiple teams", - "database migration", "schema redesign", "api redesign", - ]; - const moderateIndicators = [ - "integrate", "test suite", "component", "service", "endpoint", - "feature", "module", "api", "database", "auth", "workflow", - "deployment", "ci/cd", "pipeline", "refactor", - ]; - const simpleIndicators = [ - "fix bug", "update", "rename", "change", "add field", - "modify", "adjust", "tweak", "patch", "bump", - ]; - const trivialIndicators = [ - "typo", "comment", "formatting", "whitespace", "lint", - "readme", "docs", "changelog", "version bump", - ]; - - const hasComplexSignals = complexIndicators.some((ind) => text.includes(ind)); - const hasModerateSignals = moderateIndicators.some((ind) => text.includes(ind)); - const hasSimpleSignals = simpleIndicators.some((ind) => text.includes(ind)); - const hasTrivialSignals = trivialIndicators.some((ind) => text.includes(ind)); - - // Multiple file mentions suggest higher complexity - const fileRefs = (text.match(/\b\w+\.\w{1,5}\b/g) ?? []).length; - - // Scoring - if (hasTrivialSignals && wordCount < 20 && !hasModerateSignals && !hasComplexSignals) { - return "trivial"; - } - if (hasComplexSignals || (wordCount > 200 && hasModerateSignals) || fileRefs > 10) { - return "complex"; - } - if (hasModerateSignals || wordCount > 80 || fileRefs > 4) { - return "moderate"; - } - if (hasSimpleSignals || wordCount < 40) { - return "simple"; - } - - // Default based on word count - if (wordCount > 120) return "complex"; - if (wordCount > 60) return "moderate"; - if (wordCount > 20) return "simple"; - return "trivial"; -} - -// --------------------------------------------------------------------------- -// Parallelism Cap Scaling -// --------------------------------------------------------------------------- - -/** - * Determines the parallelism cap based on estimated scope from TeamComplexityAssessment. - * - * VAL-ENH-003: TeamManifest.parallelismCap scales with estimatedScope. - */ -export function scaleParallelismCap(estimatedScope: TeamComplexityAssessment["estimatedScope"]): number { - switch (estimatedScope) { - case "small": - return 1; - case "medium": - return 2; - case "large": - return 4; - case "very_large": - return 6; - default: - return 2; - } -} - -// --------------------------------------------------------------------------- -// Model Downgrade -// --------------------------------------------------------------------------- - -export type ModelDowngradeResult = { - downgraded: boolean; - originalModelId: string; - resolvedModelId: string; - reason: string | null; -}; - -/** - * Checks whether the current usage exceeds the model downgrade threshold and - * returns an alternate (cheaper) model ID if so. - * - * VAL-USAGE-003 / VAL-ENH runtime: Model downgraded when usage threshold exceeded. - */ -export function evaluateModelDowngrade(args: { - currentModelId: string; - downgradeThresholdPct: number | null | undefined; - currentUsagePct: number | null | undefined; - cheaperModelId?: string; -}): ModelDowngradeResult { - const { currentModelId, downgradeThresholdPct, currentUsagePct, cheaperModelId } = args; - - if ( - !downgradeThresholdPct || - downgradeThresholdPct <= 0 || - currentUsagePct == null || - currentUsagePct < downgradeThresholdPct - ) { - return { - downgraded: false, - originalModelId: currentModelId, - resolvedModelId: currentModelId, - reason: null, - }; - } - - const fallback = cheaperModelId ?? resolveCheaperModel(currentModelId); - if (fallback === currentModelId) { - return { - downgraded: false, - originalModelId: currentModelId, - resolvedModelId: currentModelId, - reason: `Usage at ${Math.round(currentUsagePct)}% exceeds threshold ${downgradeThresholdPct}%, but no cheaper model available.`, - }; - } - - return { - downgraded: true, - originalModelId: currentModelId, - resolvedModelId: fallback, - reason: `Usage at ${Math.round(currentUsagePct)}% exceeds downgrade threshold ${downgradeThresholdPct}%. Downgrading from ${currentModelId} to ${fallback}.`, - }; -} - -/** - * Simple heuristic for picking a cheaper model tier. - */ -function resolveCheaperModel(modelId: string): string { - const lower = modelId.toLowerCase(); - // Anthropic: downgrade from opus -> sonnet, sonnet -> haiku - if (lower.includes("opus")) return modelId.replace(/opus/i, "sonnet"); - if (lower.includes("sonnet") && !lower.includes("haiku")) return modelId.replace(/sonnet[^/]*/i, "haiku-3-5"); - // OpenAI: downgrade from gpt-5 -> gpt-4o - if (lower.includes("gpt-5")) return modelId.replace(/gpt-5[^/]*/i, "gpt-4o"); - if (lower.includes("gpt-4o") && !lower.includes("mini")) return modelId.replace(/gpt-4o/i, "gpt-4o-mini"); - return modelId; -} diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts deleted file mode 100644 index 49075d3be..000000000 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ /dev/null @@ -1,10432 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { PhaseCard } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createMissionService } from "../missions/missionService"; -import { createOrchestratorService } from "./orchestratorService"; -import { CoordinatorAgent } from "./coordinatorAgent"; -import { filterExecutionSteps } from "./orchestratorContext"; -import { readMissionStateDocument, updateMissionStateDocument } from "./missionStateDoc"; -import { - buildCoordinatorEvaluationActionHints, - createAiOrchestratorService, - deriveFallbackLaneStrategyDecision, - deriveMissionPhaseSyncTarget, - hasRecoverableWorkerRuntimeState, - normalizeCoordinatorUpdateForChat, -} from "./aiOrchestratorService"; - -vi.mock("@opencode-ai/sdk", () => ({ - createOpencodeServer: vi.fn(async () => ({ - url: "http://mock-opencode-server", - close: vi.fn(), - })), - createOpencodeClient: vi.fn(() => ({})), -})); - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function phaseCard(overrides: Partial & Pick): PhaseCard { - return { - id: `phase-${overrides.phaseKey}`, - description: `${overrides.name} phase`, - instructions: `${overrides.name} instructions`, - model: { provider: "codex", modelId: "openai/gpt-5.3-codex", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - ...overrides, - }; -} - -const VALID_PLANNER_PLAN = JSON.stringify({ - schemaVersion: "1.0", - missionSummary: { - title: "Execute mission", - objective: "Deliver the requested changes", - domain: "mixed", - complexity: "medium", - strategy: "sequential", - parallelismCap: 1 - }, - assumptions: [], - risks: [], - steps: [ - { - stepId: "implement-changes", - name: "Implement requested changes", - description: "Write and apply the code changes described in the mission prompt.", - taskType: "code", - executorHint: "either", - preferredScope: "lane", - requiresContextProfiles: ["deterministic"], - dependencies: [], - artifactHints: [], - claimPolicy: { lanes: ["backend"] }, - maxAttempts: 2, - retryPolicy: { baseMs: 5000, maxMs: 120000, multiplier: 2, maxRetries: 1 }, - outputContract: { expectedSignals: ["code_written"], completionCriteria: "code_written" } - } - ], - handoffPolicy: { externalConflictDefault: "intervention" } -}); - -describe("health sweep worker-state guard", () => { - const workerState = (state: string) => ({ - attemptId: `attempt-${state}`, - stepId: `step-${state}`, - runId: "run-1", - sessionId: null, - executorKind: "codex", - state, - lastHeartbeatAt: "2026-05-07T00:00:00.000Z", - spawnedAt: "2026-05-07T00:00:00.000Z", - completedAt: state === "completed" || state === "failed" ? "2026-05-07T00:00:01.000Z" : null, - outcomeTags: [], - } as any); - - it("does not treat preserved terminal workers as recoverable runtime work", () => { - expect(hasRecoverableWorkerRuntimeState([ - workerState("completed"), - workerState("failed"), - workerState("disposed"), - ])).toBe(false); - }); - - it("keeps active worker states eligible for health sweeps", () => { - expect(hasRecoverableWorkerRuntimeState([ - workerState("completed"), - workerState("working"), - ])).toBe(true); - }); -}); - -function buildDefaultDecisionStructuredOutput(prompt: string): Record | null { - const normalized = prompt.toLowerCase(); - if (normalized.includes("decision: lane strategy")) { - const stepKeys = [...prompt.matchAll(/"stepKey"\s*:\s*"([^"]+)"/g)] - .map((match) => match[1]?.trim()) - .filter((value): value is string => Boolean(value)); - const uniqueStepKeys = [...new Set(stepKeys)]; - const stepAssignments = uniqueStepKeys.map((stepKey, index) => ({ - stepKey, - laneLabel: index === 1 ? "parallel-1" : "base", - rationale: index === 1 ? "Run an independent branch in a child lane." : "Keep on the base lane." - })); - return { - strategy: "dependency_parallel", - maxParallelLanes: 3, - rationale: "Independent steps can run in parallel lanes.", - confidence: 0.9, - stepAssignments - }; - } - if (normalized.includes("decision: state transition")) { - return { - actionType: "continue", - reason: "No blockers detected.", - rationale: "The completed step can transition forward safely.", - nextStatus: null, - retryDelayMs: null, - timeoutBudgetMs: null, - confidence: 0.86 - }; - } - if (normalized.includes("decision: parallelism cap")) { - return { - parallelismCap: 2, - rationale: "Use conservative mission-start parallelism.", - confidence: 0.78 - }; - } - if (normalized.includes("decision: retry policy")) { - return { - shouldRetry: true, - delayMs: 5000, - reason: "Transient failure pattern detected.", - adjustedHint: null, - confidence: 0.75 - }; - } - if (normalized.includes("decision: timeout budget")) { - return { - timeoutMs: 120000, - rationale: "Default timeout budget for this step category.", - confidence: 0.72 - }; - } - if (normalized.includes("decision: step priority")) { - return { - priority: 50, - laneHint: null, - rationale: "Balanced default priority.", - confidence: 0.7 - }; - } - if (normalized.includes("decision: stagnation evaluation")) { - return { - isStagnating: false, - severity: "low", - recommendedAction: "continue", - rationale: "Recent activity indicates forward progress.", - confidence: 0.7 - }; - } - if (normalized.includes("decision: quality gate")) { - return { - verdict: "pass", - reason: "No blocking findings.", - blockingFindings: [], - confidence: 0.82 - }; - } - if (normalized.includes("decision: recovery action")) { - return { - action: "retry_with_hint", - reason: "Retrying with explicit guidance is safest.", - retryHint: "Retry once with focused diagnostics.", - confidence: 0.68 - }; - } - if (normalized.includes("decision: mission replan")) { - return { - shouldReplan: false, - summary: "Current mission plan remains valid.", - planDelta: [], - confidence: 0.74 - }; - } - return null; -} - -describe("buildCoordinatorEvaluationActionHints", () => { - it("nudges the coordinator to leave planning after planning work completes", () => { - const hints = buildCoordinatorEvaluationActionHints({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - }, - }, - steps: [ - { - id: "step-1", - stepKey: "worker-plan", - title: "Planning worker", - status: "succeeded", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, - ], - attempts: [], - runtimeEvents: [], - timeline: [], - } as any); - - expect(hints).toContain( - "REQUIRED NEXT ACTION: Planning work is complete. Call set_current_phase with phaseKey \"development\" before spawning any implementation workers." - ); - expect(hints).toContain( - "REQUIRED NEXT ACTION: The run has no executable work left but is still in planning. Advance to the next phase and spawn the implementation work, or call fail_mission if the mission cannot proceed." - ); - }); - - it("nudges the coordinator to finalize once executable work is done outside planning", () => { - const hints = buildCoordinatorEvaluationActionHints({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - }, - }, - steps: [ - { - id: "step-1", - stepKey: "worker-impl", - title: "Implementation worker", - status: "succeeded", - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - }, - }, - ], - attempts: [], - runtimeEvents: [], - timeline: [], - } as any); - - expect(hints).toEqual([ - "REQUIRED NEXT ACTION: All executable steps are terminal. If the mission goal is satisfied, call complete_mission with a concise summary. Otherwise create or spawn the missing follow-up work now." - ]); - }); -}); - -describe("deriveMissionPhaseSyncTarget", () => { - it("holds the current configured phase after planning execution completes", () => { - const target = deriveMissionPhaseSyncTarget({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - phaseOverride: [ - { - phaseKey: "planning", - name: "Planning", - position: 0, - model: { modelId: "anthropic/claude-sonnet-4-6" }, - instructions: "Plan first", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - { - phaseKey: "development", - name: "Development", - position: 1, - model: { modelId: "openai/gpt-5.3-codex" }, - instructions: "Implement the work", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - ], - }, - }, - steps: [ - { - id: "worker-1", - stepIndex: 0, - stepKey: "plan-test-tab", - title: "Plan Test tab", - status: "succeeded", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - phasePosition: 0, - stepType: "planning", - phaseModel: { modelId: "anthropic/claude-sonnet-4-6" }, - phaseInstructions: "Plan first", - phaseValidation: { tier: "self", required: false }, - phaseBudget: {}, - }, - }, - ], - attempts: [], - runtimeEvents: [], - timeline: [], - } as any); - - expect(target).toMatchObject({ - phaseKey: "planning", - phaseName: "Planning", - phaseInstructions: "Plan first", - sourceStepId: "worker-1", - }); - }); - - it("follows the active executable worker phase when work is still running", () => { - const target = deriveMissionPhaseSyncTarget({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - }, - }, - steps: [ - { - id: "worker-1", - stepIndex: 0, - stepKey: "worker-impl", - title: "Implementation worker", - status: "running", - metadata: { - phaseKey: "development", - phaseName: "Development", - phasePosition: 1, - phaseModel: { modelId: "openai/gpt-5.3-codex" }, - phaseInstructions: "Write the code", - phaseValidation: { tier: "self", required: false }, - phaseBudget: {}, - }, - }, - ], - attempts: [], - runtimeEvents: [], - timeline: [], - } as any); - - expect(target).toMatchObject({ - phaseKey: "development", - phaseName: "Development", - phaseInstructions: "Write the code", - sourceStepId: "worker-1", - }); - }); - - it("holds the newly entered configured phase when no execution step exists for it yet", () => { - const target = deriveMissionPhaseSyncTarget({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - phaseOverride: [ - { - phaseKey: "planning", - name: "Planning", - position: 0, - model: { modelId: "anthropic/claude-sonnet-4-6" }, - instructions: "Plan first", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - { - phaseKey: "development", - name: "Development", - position: 1, - model: { modelId: "openai/gpt-5.3-codex" }, - instructions: "Implement the work", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - ], - }, - }, - steps: [ - { - id: "worker-1", - stepIndex: 1, - stepKey: "plan-test-tab", - title: "Plan Test tab", - status: "succeeded", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - phasePosition: 0, - stepType: "planning", - phaseModel: { modelId: "anthropic/claude-sonnet-4-6" }, - phaseInstructions: "Plan first", - phaseValidation: { tier: "self", required: false }, - phaseBudget: {}, - }, - }, - ], - attempts: [], - runtimeEvents: [], - timeline: [], - } as any); - - expect(target).toMatchObject({ - phaseKey: "development", - phaseName: "Development", - phaseInstructions: "Implement the work", - sourceStepId: null, - }); - }); - - it("syncMissionFromRun does not auto-transition configured phases after planning succeeds", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Keep explicit coordinator control over phase transitions.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - phaseOverride: [ - { - phaseKey: "planning", - name: "Planning", - position: 0, - model: { modelId: "anthropic/claude-sonnet-4-6" }, - instructions: "Plan first", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - { - phaseKey: "development", - name: "Development", - position: 1, - model: { modelId: "openai/gpt-5.3-codex" }, - instructions: "Implement the work", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - ], - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { modelId: "anthropic/claude-sonnet-4-6" }, - currentPhaseInstructions: "Plan first", - currentPhaseValidation: { tier: "self", required: false }, - currentPhaseBudget: {}, - transitionedAt: "2026-03-01T00:00:00.000Z", - transitions: [ - { - fromPhaseKey: null, - fromPhaseName: null, - toPhaseKey: "planning", - toPhaseName: "Planning", - at: "2026-03-01T00:00:00.000Z", - reason: "run_initialized", - }, - ], - phaseBudgets: { - planning: { - enteredAt: "2026-03-01T00:00:00.000Z", - usedTokens: 0, - usedCostUsd: 0, - }, - }, - }, - }, - steps: [ - { - stepKey: "plan-step", - title: "Plan the mission", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - phaseInstructions: "Plan first", - }, - }, - ], - }); - - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - fixture.orchestratorService.tick({ runId: started.run.id }); - const planningStep = fixture.orchestratorService.listSteps(started.run.id)[0]; - if (!planningStep) throw new Error("Expected planning step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: started.run.id, - stepId: planningStep.id, - ownerId: "planner-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Planning complete.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - await fixture.aiOrchestratorService.syncMissionFromRun(started.run.id, "planning_completed"); - - const refreshed = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 50 }); - const phaseRuntime = refreshed.run.metadata?.phaseRuntime as Record | undefined; - expect(phaseRuntime?.currentPhaseKey).toBe("planning"); - expect(phaseRuntime?.currentPhaseName).toBe("Planning"); - expect(Array.isArray(phaseRuntime?.transitions) ? phaseRuntime?.transitions : []).toHaveLength(1); - expect(refreshed.timeline.filter((entry) => entry.eventType === "phase_transition")).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("deduplicates downstream handoff summaries when completion events replay", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Avoid duplicate downstream handoff context on replay.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "alpha", - title: "Alpha", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation" }, - }, - { - stepKey: "beta", - title: "Beta", - stepIndex: 1, - dependencyStepKeys: ["alpha"], - executorKind: "manual", - metadata: { stepType: "test" }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - fixture.orchestratorService.tick({ runId: started.run.id }); - - const graphBefore = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const alpha = graphBefore.steps.find((entry) => entry.stepKey === "alpha"); - const beta = graphBefore.steps.find((entry) => entry.stepKey === "beta"); - if (!alpha || !beta) throw new Error("Expected alpha/beta steps"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: started.run.id, - stepId: alpha.id, - ownerId: "alpha-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Alpha finished cleanly.", - outputs: { - filesChanged: ["src/alpha.ts"], - testsPassed: 2, - testsFailed: 0, - testsSkipped: 0, - }, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - const replayEvent = { - type: "orchestrator-attempt-updated" as const, - runId: started.run.id, - stepId: alpha.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "attempt_completed", - }; - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent(replayEvent); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent(replayEvent); - - await waitFor(() => { - const refreshed = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const downstream = refreshed.steps.find((entry) => entry.id === beta.id); - const summaries = Array.isArray(downstream?.metadata?.handoffSummaries) - ? downstream?.metadata?.handoffSummaries as unknown[] - : []; - return summaries.length === 1; - }); - - const refreshed = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const downstream = refreshed.steps.find((entry) => entry.id === beta.id); - const summaries = Array.isArray(downstream?.metadata?.handoffSummaries) - ? downstream?.metadata?.handoffSummaries as string[] - : []; - const summaryKeys = Array.isArray(downstream?.metadata?.handoffSummaryKeys) - ? downstream?.metadata?.handoffSummaryKeys as string[] - : []; - expect(summaries).toHaveLength(1); - expect(summaryKeys).toEqual([`${alpha.id}:${attempt.id}`]); - } finally { - fixture.dispose(); - } - }); -}); - -describe("normalizeCoordinatorUpdateForChat", () => { - it("turns raw coordinator monologue into a short readable progress update", () => { - expect( - normalizeCoordinatorUpdateForChat( - "I have prior context about this. Let me read the key files directly.I have all the context needed. The mission is clear and well-scoped.", - ), - ).toBe("I’m reviewing the relevant files and mapping out the next step."); - }); - - it("drops tool-shaped coordinator chatter", () => { - expect(normalizeCoordinatorUpdateForChat("{\"ok\":true}")).toBeNull(); - expect(normalizeCoordinatorUpdateForChat("tool ade.spawn_worker")).toBeNull(); - }); -}); - -function createAiTaskResult(structuredOutput: Record | null, textFallback = "{}") { - return { - text: structuredOutput ? JSON.stringify(structuredOutput) : textFallback, - structuredOutput, - provider: "claude", - model: "sonnet", - sessionId: null, - inputTokens: 100, - outputTokens: 50, - durationMs: 1000 - }; -} - -function createStagnationRecoveryAiIntegrationService() { - return createMockAiIntegrationService({ - executeTask: vi.fn().mockImplementation(async (request: { prompt?: string }) => { - const prompt = String(request?.prompt ?? ""); - const structuredOutput = prompt.includes("Decision: stagnation evaluation") - ? { - isStagnating: true, - severity: "high", - recommendedAction: "continue", - rationale: "Attempt has exceeded timeout with no meaningful progress.", - confidence: 0.93 - } - : buildDefaultDecisionStructuredOutput(prompt); - return createAiTaskResult(structuredOutput); - }) - }); -} - -function createMockAiIntegrationService(overrides: { - executeTask?: (...args: any[]) => Promise; -} = {}) { - return { - getAvailability: () => ({ - claude: { - binary: { present: true, source: "path", path: "/usr/local/bin/claude" }, - auth: { ready: true, mode: "oauth", detail: null }, - }, - codex: true, - cursor: false, - droid: false, - }), - getMode: () => "subscription", - getFeatureFlag: () => true, - getDailyBudgetLimit: () => null, - getDailyUsage: () => 0, - executeTask: overrides.executeTask ?? vi.fn().mockImplementation(async (request: { prompt?: string }) => { - const prompt = String(request?.prompt ?? ""); - const structuredOutput = buildDefaultDecisionStructuredOutput(prompt); - return createAiTaskResult(structuredOutput); - }), - listModels: vi.fn().mockResolvedValue([]) - } as any; -} - -function createMockAgentChatService(overrides: { - createSession?: (...args: any[]) => Promise; - runSessionTurn?: (...args: any[]) => Promise; - sendMessage?: (...args: any[]) => Promise; - steer?: (...args: any[]) => Promise; - interrupt?: (...args: any[]) => Promise; - resumeSession?: (...args: any[]) => Promise; - listSessions?: (...args: any[]) => Promise; - dispose?: (...args: any[]) => Promise; -} = {}) { - return { - createSession: overrides.createSession ?? vi.fn().mockResolvedValue({ - id: "chat-session-1", - laneId: "lane-1", - provider: "claude", - model: "sonnet", - reasoningEffort: "medium", - status: "idle", - createdAt: "2026-02-20T00:00:00.000Z", - lastActivityAt: "2026-02-20T00:00:00.000Z" - }), - runSessionTurn: overrides.runSessionTurn ?? vi.fn().mockResolvedValue({ outputText: "Coordinator acknowledged." }), - sendMessage: overrides.sendMessage ?? vi.fn().mockResolvedValue(undefined), - steer: overrides.steer ?? vi.fn().mockResolvedValue(undefined), - interrupt: overrides.interrupt ?? vi.fn().mockResolvedValue(undefined), - resumeSession: overrides.resumeSession ?? vi.fn().mockResolvedValue({}), - listSessions: overrides.listSessions ?? vi.fn().mockResolvedValue([]), - dispose: overrides.dispose ?? vi.fn().mockResolvedValue(undefined) - } as any; -} - -function clearWorkerDeliveryBackoff(db: Awaited>, messageId: string): void { - const row = db.get<{ metadata_json: string | null }>( - ` - select metadata_json - from orchestrator_chat_messages - where id = ? - limit 1 - `, - [messageId] - ); - const metadata = row?.metadata_json ? JSON.parse(row.metadata_json) : {}; - const workerDelivery = metadata && typeof metadata.workerDelivery === "object" && !Array.isArray(metadata.workerDelivery) - ? metadata.workerDelivery - : {}; - metadata.workerDelivery = { - ...workerDelivery, - nextRetryAt: null - }; - db.run( - ` - update orchestrator_chat_messages - set metadata_json = ? - where id = ? - `, - [JSON.stringify(metadata), messageId] - ); -} - -function patchWorkerDeliveryMetadata( - db: Awaited>, - messageId: string, - patch: Record -): void { - const row = db.get<{ metadata_json: string | null }>( - ` - select metadata_json - from orchestrator_chat_messages - where id = ? - limit 1 - `, - [messageId] - ); - const metadata = row?.metadata_json ? JSON.parse(row.metadata_json) : {}; - const workerDelivery = metadata && typeof metadata.workerDelivery === "object" && !Array.isArray(metadata.workerDelivery) - ? metadata.workerDelivery - : {}; - metadata.workerDelivery = { - ...workerDelivery, - ...patch - }; - db.run( - ` - update orchestrator_chat_messages - set metadata_json = ? - where id = ? - `, - [JSON.stringify(metadata), messageId] - ); -} - -function setMissionPlanningMode( - db: Awaited>, - missionId: string, - mode: "off" | "auto" | "manual_review" -): void { - const row = db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [missionId] - ); - const metadata = row?.metadata_json ? JSON.parse(row.metadata_json) : {}; - const executionPolicy = - metadata && typeof metadata.executionPolicy === "object" && !Array.isArray(metadata.executionPolicy) - ? metadata.executionPolicy - : {}; - const planning = - executionPolicy && typeof executionPolicy.planning === "object" && !Array.isArray(executionPolicy.planning) - ? executionPolicy.planning - : {}; - executionPolicy.planning = { - ...planning, - mode, - ...(mode === "off" ? {} : { model: "codex" }) - }; - metadata.executionPolicy = executionPolicy; - db.run(`update missions set metadata_json = ? where id = ?`, [JSON.stringify(metadata), missionId]); -} - -function clearRunPhaseConfig( - db: Awaited>, - runId: string, -): void { - const row = db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - const metadata = row?.metadata_json ? JSON.parse(row.metadata_json) : {}; - delete metadata.phaseOverride; - delete metadata.phaseConfiguration; - delete metadata.phaseRuntime; - db.run(`update orchestrator_runs set metadata_json = ? where id = ?`, [JSON.stringify(metadata), runId]); -} - -function readRunMissionLaneId( - db: Awaited>, - runId: string, -): string | null { - const row = db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - const metadata = row?.metadata_json ? JSON.parse(row.metadata_json) : {}; - const direct = typeof metadata?.missionLaneId === "string" ? metadata.missionLaneId.trim() : ""; - if (direct.length > 0) return direct; - const coordinatorLaneId = - metadata?.coordinator && typeof metadata.coordinator === "object" && !Array.isArray(metadata.coordinator) - && typeof metadata.coordinator.missionLaneId === "string" - ? metadata.coordinator.missionLaneId.trim() - : ""; - if (coordinatorLaneId.length > 0) return coordinatorLaneId; - const teamRuntimeLaneId = - metadata?.teamRuntime && typeof metadata.teamRuntime === "object" && !Array.isArray(metadata.teamRuntime) - && typeof metadata.teamRuntime.missionLaneId === "string" - ? metadata.teamRuntime.missionLaneId.trim() - : ""; - return teamRuntimeLaneId.length > 0 ? teamRuntimeLaneId : null; -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { - const started = Date.now(); - while (!predicate()) { - if (Date.now() - started > timeoutMs) { - throw new Error("Timed out waiting for condition"); - } - await new Promise((resolve) => setTimeout(resolve, 15)); - } -} - -function markRunStepValidationPassed( - db: Awaited>, - runId: string, -): void { - const steps = db.all<{ id: string; metadata_json: string | null }>( - `select id, metadata_json from orchestrator_steps where run_id = ?`, - [runId], - ); - const now = new Date().toISOString(); - for (const step of steps) { - const metadata = step.metadata_json ? JSON.parse(step.metadata_json) : {}; - if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { - metadata.validationState = "pass"; - metadata.validationPassedAt = now; - metadata.lastValidationReport = { - verdict: "pass", - summary: "Validation accepted in test fixture.", - at: now, - }; - } - db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ?`, - [JSON.stringify(metadata), now, step.id], - ); - } -} - -async function createFixture(args: { - aiIntegrationService?: any; - laneService?: any; - prService?: any; - agentChatService?: any; - orchestratorConfig?: Record; - logger?: any; - hookCommandRunner?: any; -} = {}) { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ai-orchestrator-")); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "SYSTEM_OVERVIEW.md"), "# Architecture\n", "utf8"); - - const db = await openKvDb(path.join(projectRoot, ".ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const now = "2026-02-20T00:00:00.000Z"; - - db.run( - ` - insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?) - `, - [projectId, projectRoot, "ADE", "main", now, now] - ); - db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - laneId, - projectId, - "Lane 1", - null, - "worktree", - "main", - "feature/lane-1", - projectRoot, - null, - 0, - null, - null, - null, - null, - "active", - now, - null - ] - ); - - const missionService = createMissionService({ db, projectId }); - let defaultLaneCounter = 0; - const defaultLaneService = { - list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => db.all<{ - id: string; - project_id: string; - name: string; - lane_type: string | null; - base_ref: string | null; - branch_ref: string | null; - worktree_path: string | null; - attached_root_path: string | null; - status: string | null; - archived_at: string | null; - mission_id: string | null; - lane_role: string | null; - }>( - ` - select - id, - project_id, - name, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - status, - archived_at, - mission_id, - lane_role - from lanes - where project_id = ? - and (? = 1 or archived_at is null) - order by created_at asc, id asc - `, - [projectId, includeArchived ? 1 : 0] - ).map((row) => ({ - id: row.id, - projectId: row.project_id, - name: row.name, - laneType: row.lane_type === "primary" ? "primary" : "worktree", - baseRef: row.base_ref, - branchRef: row.branch_ref, - worktreePath: row.worktree_path, - attachedRootPath: row.attached_root_path, - status: row.archived_at ? "archived" : row.status === "archived" ? "archived" : "active", - missionId: row.mission_id, - laneRole: row.lane_role, - archivedAt: row.archived_at, - }))), - createChild: vi.fn(async ({ name, description, missionId, laneRole }: { - name: string; - description?: string | null; - missionId?: string | null; - laneRole?: string | null; - }) => { - defaultLaneCounter += 1; - const childId = `mission-lane-${defaultLaneCounter}`; - const childNow = new Date().toISOString(); - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - childId, - projectId, - name, - description ?? null, - "worktree", - "main", - `feature/${childId}`, - projectRoot, - null, - 0, - laneId, - null, - null, - null, - null, - missionId ?? null, - laneRole ?? null, - "active", - childNow, - null, - ] - ); - return { - id: childId, - name, - laneType: "worktree", - branchRef: `feature/${childId}`, - worktreePath: projectRoot, - missionId: missionId ?? null, - laneRole: laneRole ?? null, - }; - }), - archive: vi.fn(async ({ laneId: targetLaneId }: { laneId: string }) => { - db.run( - `update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?`, - [new Date().toISOString(), targetLaneId, projectId] - ); - }), - setMissionOwnership: vi.fn(async ({ - laneId: targetLaneId, - missionId, - laneRole, - }: { - laneId: string; - missionId: string | null; - laneRole?: string | null; - }) => { - db.run( - `update lanes set mission_id = ?, lane_role = ? where id = ? and project_id = ?`, - [missionId, laneRole ?? null, targetLaneId, projectId] - ); - }), - getLaneWorktreePath: vi.fn((targetLaneId: string) => { - const row = db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? and project_id = ? limit 1`, - [targetLaneId, projectId] - ); - return row?.worktree_path ?? projectRoot; - }), - }; - const laneService = args.laneService ?? defaultLaneService; - let defaultIntegrationLaneCounter = 0; - const defaultPrService = { - createIntegrationLane: vi.fn(async ({ - sourceLaneIds, - integrationLaneName, - missionId, - laneRole, - }: { - sourceLaneIds: string[]; - integrationLaneName: string; - missionId?: string | null; - laneRole?: string | null; - }) => { - defaultIntegrationLaneCounter += 1; - const integrationLaneId = `result-lane-${defaultIntegrationLaneCounter}`; - const createdAt = new Date().toISOString(); - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - integrationLaneId, - projectId, - integrationLaneName, - null, - "worktree", - "main", - `integration/${integrationLaneId}`, - projectRoot, - null, - 0, - laneId, - null, - null, - null, - null, - missionId ?? null, - laneRole ?? "result", - "active", - createdAt, - null, - ] - ); - return { - integrationLane: { - id: integrationLaneId, - name: integrationLaneName, - laneType: "worktree", - branchRef: `integration/${integrationLaneId}`, - worktreePath: projectRoot, - missionId: missionId ?? null, - laneRole: laneRole ?? "result", - }, - mergeResults: sourceLaneIds.map((sourceLaneId) => ({ laneId: sourceLaneId, success: true })), - }; - }), - }; - const prService = args.prService ?? defaultPrService; - const aiIntegrationService = "aiIntegrationService" in args ? args.aiIntegrationService : createMockAiIntegrationService(); - const projectConfigService = { - get: () => ({ - effective: { - ai: { - orchestrator: { - ...(args.orchestratorConfig ?? {}) - } - } - } - }) - } as any; - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - projectConfigService - }); - const defaultOpenCodeModelId = "anthropic/claude-sonnet-4-6"; - const normalizeStepModelId = (step: any) => { - const executorKind = typeof step?.executorKind === "string" ? step.executorKind : null; - if (executorKind !== "opencode") return step; - const metadata = - step?.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata) - ? step.metadata - : {}; - const modelId = typeof metadata.modelId === "string" ? metadata.modelId.trim() : ""; - if (modelId.length > 0) return { ...step, metadata: { ...metadata, modelId } }; - return { - ...step, - metadata: { - ...metadata, - modelId: defaultOpenCodeModelId, - }, - }; - }; - const originalStartRun = orchestratorService.startRun.bind(orchestratorService); - (orchestratorService as any).startRun = ((input: any) => - originalStartRun({ - ...input, - steps: Array.isArray(input?.steps) ? input.steps.map((step: any) => normalizeStepModelId(step)) : input?.steps, - })) as typeof orchestratorService.startRun; - - const aiOrchestratorService = createAiOrchestratorService({ - db, - logger: args.logger ?? createLogger(), - missionService, - orchestratorService, - agentChatService: args.agentChatService ?? null, - laneService, - projectConfigService, - aiIntegrationService, - prService, - projectRoot, - hookCommandRunner: args.hookCommandRunner - }); - - return { - db, - projectId, - projectRoot, - laneId, - missionService, - orchestratorService, - laneService, - prService, - projectConfigService, - aiIntegrationService, - aiOrchestratorService, - dispose: () => { - aiOrchestratorService.dispose(); - db.close(); - } - }; -} - -describe("aiOrchestratorService", () => { - it("starts mission directly without opening a pre-run approval gate", async () => { - const fixture = await createFixture(); - let started: any; - try { - const mission = fixture.missionService.create({ - prompt: "Implement orchestration startup policy and summarize outcomes.", - laneId: fixture.laneId - }); - - started = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode" - }); - // The mission now starts directly in orchestrator runtime. - expect(started.started).toBeTruthy(); - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("in_progress"); - expect(refreshed?.interventions ?? []).toHaveLength(0); - expect(fixture.orchestratorService.listRuns({ missionId: mission.id }).length).toBe(1); - } finally { - fixture.dispose(); - } - }); - - it("starts mission directly in AI-first flow", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Plan and implement runtime resiliency improvements.", - laneId: fixture.laneId - }); - - // In AI-first flow, startMissionRun goes directly to in_progress. - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode" - }); - expect(launched.started).toBeTruthy(); - expect(launched.started?.run.id).toBeTruthy(); - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("in_progress"); - expect(fixture.orchestratorService.listRuns({ missionId: mission.id }).length).toBe(1); - } finally { - fixture.dispose(); - } - }); - - it("emits immediate coordinator lifecycle status before any reasoning stream arrives", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Plan the startup path cleanly.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - - const coordinatorThread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((thread) => thread.threadType === "coordinator"); - expect(coordinatorThread).toBeTruthy(); - const coordinatorMessages = coordinatorThread - ? fixture.aiOrchestratorService.getThreadMessages({ missionId: mission.id, threadId: coordinatorThread.id, limit: 50 }) - : []; - expect(coordinatorMessages.some((entry) => entry.content.includes("I’m online and getting the run ready."))).toBe(true); - expect(coordinatorMessages.some((entry) => entry.content.includes("I’m reading your prompt and sizing up the work."))).toBe(true); - - const lifecycleEvents = fixture.orchestratorService - .listRuntimeEvents({ runId, eventTypes: ["progress"], limit: 50 }) - .filter((entry) => { - const payload = entry.payload as Record | null; - return payload?.source === "coordinator_lifecycle"; - }); - const lifecycleStates = lifecycleEvents.map((entry) => - String((entry.payload as Record | null)?.state ?? ""), - ); - expect(lifecycleStates).toContain("booting"); - expect(lifecycleStates).toContain("analyzing_prompt"); - - const runGraph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 50 }); - expect(runGraph.run.metadata).toMatchObject({ - coordinator: { - lifecycleState: "analyzing_prompt", - lifecycleMessage: "I’m reading your prompt and sizing up the work.", - }, - }); - } finally { - fixture.dispose(); - } - }); - - it("persists planning startup lifecycle transitions from coordinator tool events", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Plan first and expose each startup phase.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: "get_project_context", - args: {}, - turnId: "turn-1", - itemId: "tool-ctx", - }); - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: "spawn_worker", - args: { name: "planning-worker" }, - turnId: "turn-1", - itemId: "tool-spawn", - }); - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: "spawn_worker", - result: { ok: true, launched: true, stepId: "planner-step-1" }, - status: "completed", - turnId: "turn-1", - itemId: "tool-spawn", - }); - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: "set_current_phase", - result: { ok: true, currentPhaseKey: "development", currentPhaseName: "Development" }, - status: "completed", - turnId: "turn-2", - itemId: "tool-phase", - }); - - await waitFor(() => { - const states = fixture.orchestratorService - .listRuntimeEvents({ runId, eventTypes: ["progress"], limit: 50 }) - .map((entry) => String((entry.payload as Record | null)?.state ?? "")); - return states.includes("fetching_project_context") - && states.includes("launching_planner") - && states.includes("waiting_on_planner") - && states.includes("running_phase"); - }); - - const chat = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(chat.some((entry) => entry.content.includes("I’m pulling project context so the planner starts with the right picture."))).toBe(true); - expect(chat.some((entry) => entry.content.includes("I’m starting the planning agent now."))).toBe(true); - expect(chat.some((entry) => entry.content.includes("The planning agent is running. I’m waiting for its result."))).toBe(true); - expect(chat.some((entry) => entry.content.includes("The coordinator is running the Development phase."))).toBe(true); - const plannerLifecycleCount = () => fixture.orchestratorService - .listRuntimeEvents({ runId, eventTypes: ["progress"], limit: 50 }) - .filter((entry) => { - const state = String((entry.payload as Record | null)?.state ?? ""); - return state === "launching_planner" || state === "waiting_on_planner"; - }).length; - const plannerLifecycleCountAfterPhaseChange = plannerLifecycleCount(); - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: "spawn_worker", - args: { name: "development-worker" }, - turnId: "turn-3", - itemId: "tool-dev-spawn", - }); - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: "spawn_worker", - result: { ok: true, launched: true, stepId: "development-step-1" }, - status: "completed", - turnId: "turn-3", - itemId: "tool-dev-spawn", - }); - expect(plannerLifecycleCount()).toBe(plannerLifecycleCountAfterPhaseChange); - const runView = await fixture.aiOrchestratorService.getRunView({ - missionId: mission.id, - runId, - }); - expect(runView?.coordinator).toMatchObject({ - available: true, - mode: "continuation_required", - summary: "The coordinator is running the Development phase.", - }); - } finally { - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("copies launch employeeAgentId into orchestrator run metadata", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Launch a mission on behalf of a persistent employee.", - laneId: fixture.laneId, - employeeAgentId: "employee-42", - }); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - - expect(launched.started).toBeTruthy(); - expect(launched.started?.run.metadata?.employeeAgentId).toBe("employee-42"); - } finally { - fixture.dispose(); - } - }); - - it("opens a structured intervention when planner launch fails during coordinator startup", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Plan first, then implement the change.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - (capturedCoordinator as any)?.deps.onPlanningStartupFailure?.({ - category: "provider_unreachable", - reasonCode: "planner_launch_provider_unreachable", - interventionType: "provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - message: "Model provider timeout while launching the planning worker.", - title: "Planner launch is blocked by the model provider", - body: "ADE could not launch the planning worker because the model provider timed out.", - requestedAction: "Retry the planner, switch to a fallback model if one is available, or cancel the run.", - retryCount: 1, - }); - - await waitFor(() => { - const refreshedMission = fixture.missionService.get(mission.id); - return Boolean( - refreshedMission?.interventions.some( - (entry) => - entry.status === "open" - && entry.interventionType === "provider_unreachable" - && String(entry.metadata?.reasonCode ?? "") === "planner_launch_provider_unreachable", - ), - ); - }); - - const refreshedMission = fixture.missionService.get(mission.id); - const intervention = refreshedMission?.interventions.find( - (entry) => - entry.status === "open" - && entry.interventionType === "provider_unreachable" - && String(entry.metadata?.reasonCode ?? "") === "planner_launch_provider_unreachable", - ); - expect(intervention).toBeTruthy(); - expect(refreshedMission?.status).toBe("intervention_required"); - - const chat = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(chat.some((entry) => entry.content.includes("The planner hit a launch issue, so I paused the run and opened recovery options."))).toBe(true); - - const lifecycleEvents = fixture.orchestratorService - .listRuntimeEvents({ runId, eventTypes: ["progress"], limit: 50 }) - .filter((entry) => { - const payload = entry.payload as Record | null; - return payload?.source === "coordinator_lifecycle" && payload?.state === "planner_launch_failed"; - }); - expect(lifecycleEvents.length).toBeGreaterThan(0); - - const runGraph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }); - expect(runGraph.run.metadata).toMatchObject({ - coordinator: { - lifecycleState: "planner_launch_failed", - }, - }); - } finally { - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("pauses the run and opens recovery when the coordinator runtime exits mid-turn", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Run the coordinator and recover cleanly if it crashes.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - (capturedCoordinator as any)?.deps.onCoordinatorRuntimeFailure?.({ - category: "cli_runtime_failure", - reasonCode: "coordinator_runtime_cli_exit", - interventionType: "unrecoverable_error", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - message: "Codex CLI exited with code 1", - title: "Coordinator runtime exited unexpectedly", - body: "ADE paused the run because the coordinator process exited during execution. Error: Codex CLI exited with code 1.", - requestedAction: "Inspect coordinator runtime health, then resume the run to retry the same provider and mission state.", - turnId: "coord-turn-1", - }); - - await waitFor(() => fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status === "paused"); - - const refreshedMission = fixture.missionService.get(mission.id); - const intervention = refreshedMission?.interventions.find( - (entry) => - entry.status === "open" - && String(entry.metadata?.reasonCode ?? "") === "coordinator_runtime_cli_exit", - ); - expect(intervention).toBeTruthy(); - expect(refreshedMission?.status).toBe("intervention_required"); - - const runView = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId }); - expect(runView?.lifecycle.displayStatus).toBe("blocked"); - expect(runView?.coordinator.available).toBe(false); - expect(runView?.haltReason?.title).toBe("Coordinator runtime exited unexpectedly"); - const offlineProgress = runView?.progressLog.find((item) => item.title === "Orchestrator offline"); - expect(offlineProgress?.audience).toBe("timeline"); - expect( - runView?.progressLog.some((item) => item.title === "Coordinator runtime exited unexpectedly"), - ).toBe(true); - } finally { - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("recreates the coordinator before resuming a paused run", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Resume cleanly after the coordinator is intentionally torn down.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - (capturedCoordinator as any)?.deps.onCoordinatorRuntimeFailure?.({ - category: "cli_runtime_failure", - reasonCode: "coordinator_runtime_cli_exit", - interventionType: "unrecoverable_error", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - message: "Codex CLI exited with code 1", - title: "Coordinator runtime exited unexpectedly", - body: "ADE paused the run because the coordinator process exited during execution. Error: Codex CLI exited with code 1.", - requestedAction: "Inspect coordinator runtime health, then resume the run to retry the same provider and mission state.", - turnId: "coord-turn-1", - }); - - await waitFor(() => fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status === "paused"); - - fixture.aiOrchestratorService.resumeRun({ runId }); - await waitFor(() => fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status === "active"); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-run-updated", - runId, - at: new Date().toISOString(), - reason: "heartbeat", - } as any); - - const refreshedMission = fixture.missionService.get(mission.id); - expect(fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status).toBe("active"); - expect( - refreshedMission?.interventions.some((entry) => - entry.status === "open" - && (String(entry.metadata?.reasonCode ?? "") === "coordinator_unavailable" - || String(entry.metadata?.reasonCode ?? "") === "coordinator_recovery_failed"), - ) ?? false, - ).toBe(false); - - const runView = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId }); - expect(runView?.coordinator.available).not.toBe(false); - } finally { - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("retries finalization when resuming a run already in completing status", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Retry completion after a completion gate fix.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "done", - title: "Done", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation" }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'completing', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - - const finalizeRunSpy = vi.spyOn(fixture.orchestratorService, "finalizeRun"); - - fixture.aiOrchestratorService.resumeRun({ runId: started.run.id }); - - expect(finalizeRunSpy).toHaveBeenCalledWith({ runId: started.run.id }); - } finally { - fixture.dispose(); - } - }); - - it("counts runtime self-validation and risk notes toward closeout requirements", async () => { - const fixture = await createFixture(); - try { - const phase = { - id: "phase-runtime-checks", - phaseKey: "runtime_checks", - name: "Runtime checks", - description: "Validate runtime output.", - instructions: "Run checks and report risks.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "Runtime checks pass.", - evidenceRequirements: ["risk_notes"], - }, - 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 closeout evidence from runtime reports.", - laneId: fixture.laneId, - phaseOverride: [phase as any], - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "runtime-checks", - title: "Runtime checks", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - phaseKey: "runtime_checks", - phaseName: "Runtime checks", - stepType: "implementation", - }, - }, - ], - }); - const step = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }).steps[0]; - if (!step) throw new Error("Expected seeded step"); - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - metadata_json = ?, - updated_at = ? - where id = ? - `, - [ - JSON.stringify({ - ...(step.metadata ?? {}), - validationContract: { required: true }, - validationState: "pass", - validationPassedAt: "2026-05-05T00:01:00.000Z", - lastValidationReport: { verdict: "pass" }, - lastResultReport: { - summary: "Runtime validation passed. Risks: no remaining runtime risks identified.", - }, - }), - "2026-05-05T00:01:00.000Z", - step.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 validationVerdict = view?.closeoutRequirements.find((entry) => entry.key === "validation_verdict"); - const riskNotes = view?.closeoutRequirements.find((entry) => entry.key === "risk_notes"); - - expect(validationVerdict?.status).toBe("present"); - expect(riskNotes?.status).toBe("present"); - expect(riskNotes?.source).toBe("runtime"); - } finally { - fixture.dispose(); - } - }); - - 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(); - const fixture = await createFixture({ logger }); - try { - const phase = { - id: "phase-runtime-checks", - phaseKey: "runtime_checks", - name: "Runtime checks", - description: "Validate runtime output.", - instructions: "Run checks and report risks.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "Runtime checks pass.", - evidenceRequirements: ["risk_notes"], - }, - 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: "Block until risk notes are visible.", - laneId: fixture.laneId, - phaseOverride: [phase as any], - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "runtime-checks", - title: "Runtime checks", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - phaseKey: "runtime_checks", - phaseName: "Runtime checks", - stepType: "implementation", - }, - }, - ], - }); - const step = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }).steps[0]; - if (!step) throw new Error("Expected seeded step"); - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - metadata_json = ?, - updated_at = ? - where id = ? - `, - [ - JSON.stringify({ - ...(step.metadata ?? {}), - validationContract: { required: true }, - validationState: "pass", - validationPassedAt: "2026-05-05T00:01:00.000Z", - lastValidationReport: { verdict: "pass" }, - lastResultReport: { summary: "Runtime validation passed." }, - }), - "2026-05-05T00:01:00.000Z", - step.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 riskNotes = view?.closeoutRequirements.find((entry) => entry.key === "risk_notes"); - - expect(view?.lifecycle.displayStatus).toBe("blocked"); - expect(view?.haltReason?.title).toBe("Closeout incomplete"); - expect(riskNotes?.status).toBe("missing"); - expect(logger.info).not.toHaveBeenCalledWith( - "ai_orchestrator.coordinator_agent_v2_started", - expect.objectContaining({ runId: started.run.id }), - ); - } finally { - fixture.dispose(); - } - }); - - it("counts passing risk-aware validation as risk-note closeout evidence", async () => { - const fixture = await createFixture(); - try { - const phase = { - id: "phase-risk-aware-closeout", - phaseKey: "risk_aware_closeout", - name: "Risk-aware closeout", - description: "Validate closeout risks.", - instructions: "Run closeout validation and report remaining risks.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "Remaining risks are visible before completion.", - evidenceRequirements: ["risk_notes"], - }, - 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 risk-aware closeout validation.", - laneId: fixture.laneId, - phaseOverride: [phase as any], - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "closeout", - title: "Closeout", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - phaseKey: "risk_aware_closeout", - phaseName: "Risk-aware closeout", - stepType: "closeout", - phaseValidation: phase.validationGate, - }, - }, - { - stepKey: "validate_closeout", - title: "Validate: Closeout", - stepIndex: 1, - dependencyStepKeys: ["closeout"], - executorKind: "manual", - metadata: { - phaseKey: "risk_aware_closeout", - phaseName: "Risk-aware closeout", - stepType: "validation", - phaseValidation: phase.validationGate, - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Outcome summary, changed-files summary, and remaining risks are visible before completion.", - evidence: [], - maxRetries: 2, - }, - }, - }, - ], - }); - const graph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const step = graph.steps.find((entry) => entry.stepKey === "closeout"); - const validatorStep = graph.steps.find((entry) => entry.stepKey === "validate_closeout"); - if (!step) throw new Error("Expected seeded step"); - if (!validatorStep) throw new Error("Expected seeded validator step"); - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - metadata_json = ?, - updated_at = ? - where id = ? - `, - [ - JSON.stringify({ - ...(step.metadata ?? {}), - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Outcome summary, changed-files summary, and remaining risks are visible before completion.", - evidence: [], - maxRetries: 2, - }, - validationState: "pass", - validationPassedAt: "2026-05-05T00:01:00.000Z", - lastValidationReport: { - verdict: "pass", - summary: "Closeout validation passed.", - findings: [], - }, - lastResultReport: { summary: "Closeout worker finished." }, - }), - "2026-05-05T00:01:00.000Z", - step.id, - ], - ); - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - updated_at = ? - where id = ? - `, - [ - "2026-05-05T00:01:00.000Z", - validatorStep.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], - ); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - await updateMissionStateDocument({ - projectRoot: fixture.projectRoot, - missionId: mission.id, - runId: started.run.id, - goal: mission.prompt, - patch: { - finalization: { - policy: { - kind: "result_lane", - targetBranch: null, - draft: null, - prDepth: null, - autoRebase: null, - ciGating: null, - autoLand: null, - autoResolveConflicts: null, - archiveLaneOnLand: null, - mergeMethod: null, - conflictResolverModel: null, - reasoningEffort: null, - description: "Assemble a single result lane for the mission and stop before external PR creation.", - }, - status: "finalizing", - executionComplete: true, - contractSatisfied: false, - blocked: false, - blockedReason: null, - summary: "Execution finished, but the closeout contract is still incomplete.", - detail: "risk notes", - resolverJobId: null, - integrationLaneId: null, - resultLaneId: fixture.laneId, - queueGroupId: null, - queueId: null, - activePrId: null, - waitReason: null, - proposalUrl: null, - prUrls: [], - reviewStatus: null, - mergeReadiness: null, - requirements: [], - warnings: [], - updatedAt: "2026-05-05T00:01:00.000Z", - startedAt: "2026-05-05T00:01:00.000Z", - completedAt: null, - }, - }, - }); - - const view = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId: started.run.id }); - const riskNotes = view?.closeoutRequirements.find((entry) => entry.key === "risk_notes"); - const validationVerdict = view?.closeoutRequirements.find((entry) => entry.key === "validation_verdict"); - const missionAfterView = fixture.missionService.get(mission.id); - const stateDocAfterView = await readMissionStateDocument({ projectRoot: fixture.projectRoot, runId: started.run.id }); - - expect(riskNotes?.status).toBe("present"); - expect(riskNotes?.source).toBe("runtime"); - expect(riskNotes?.detail).toContain("No unresolved risk findings"); - expect(validationVerdict?.detail).toBe("Validation passed."); - expect(view?.lifecycle.displayStatus).toBe("completed"); - expect(missionAfterView?.status).toBe("completed"); - expect(stateDocAfterView?.finalization?.contractSatisfied).toBe(true); - expect(stateDocAfterView?.finalization?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("uses coordinator done summaries as runtime risk-note evidence", async () => { - const fixture = await createFixture(); - try { - const phase = { - id: "phase-runtime-checks", - phaseKey: "runtime_checks", - name: "Runtime checks", - description: "Validate runtime output.", - instructions: "Run checks and report risks.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "Runtime checks pass.", - evidenceRequirements: ["risk_notes"], - }, - 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 closeout evidence from coordinator summary.", - laneId: fixture.laneId, - phaseOverride: [phase as any], - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "runtime-checks", - title: "Runtime checks", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - phaseKey: "runtime_checks", - phaseName: "Runtime checks", - stepType: "implementation", - }, - }, - ], - }); - const step = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }).steps[0]; - if (!step) throw new Error("Expected seeded step"); - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - metadata_json = ?, - updated_at = ? - where id = ? - `, - [ - JSON.stringify({ - ...(step.metadata ?? {}), - validationContract: { required: true }, - validationState: "pass", - validationPassedAt: "2026-05-05T00:01:00.000Z", - lastValidationReport: { verdict: "pass" }, - lastResultReport: { summary: "Runtime validation passed." }, - }), - "2026-05-05T00:01:00.000Z", - step.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], - ); - fixture.orchestratorService.appendRuntimeEvent({ - runId: started.run.id, - eventType: "done", - payload: { - summary: "Risk notes: no remaining runtime risks identified.", - completedBy: "coordinator", - }, - }); - - const view = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId: started.run.id }); - const riskNotes = view?.closeoutRequirements.find((entry) => entry.key === "risk_notes"); - - expect(riskNotes?.status).toBe("present"); - expect(riskNotes?.source).toBe("runtime"); - expect(view?.lifecycle.displayStatus).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("resyncs mission completion when recovered coordinator closeout hits an already terminal run", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const fixture = await createFixture(); - try { - const phase = { - id: "phase-terminal-closeout", - phaseKey: "terminal_closeout", - name: "Terminal closeout", - description: "Validate closeout from a recovered coordinator.", - instructions: "Record risk notes before closeout.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "Closeout has risk notes.", - evidenceRequirements: ["risk_notes"], - }, - 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: "Complete a terminal recovered closeout.", - laneId: fixture.laneId, - phaseOverride: [phase as any], - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ - stepKey: "terminal-closeout", - title: "Terminal closeout", - stepIndex: 1, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - phaseKey: "terminal_closeout", - phaseName: "Terminal closeout", - stepType: "implementation", - }, - }], - }); - const graph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - for (const step of graph.steps) { - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - metadata_json = ?, - updated_at = ? - where id = ? - `, - [ - JSON.stringify({ - ...(step.metadata ?? {}), - validationContract: { required: true }, - validationState: "pass", - validationPassedAt: "2026-05-05T00:01:00.000Z", - lastValidationReport: { verdict: "pass" }, - lastResultReport: { summary: "Implementation passed validation." }, - }), - "2026-05-05T00:01:00.000Z", - step.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", runId], - ); - fixture.orchestratorService.appendRuntimeEvent({ - runId, - eventType: "done", - payload: { - summary: "Risk notes: no remaining product risks identified.", - completedBy: "coordinator", - }, - }); - await updateMissionStateDocument({ - projectRoot: fixture.projectRoot, - missionId: mission.id, - runId, - goal: mission.prompt, - patch: { - finalization: { - policy: { - kind: "result_lane", - targetBranch: null, - draft: null, - prDepth: null, - autoRebase: null, - ciGating: null, - autoLand: null, - autoResolveConflicts: null, - archiveLaneOnLand: null, - mergeMethod: null, - conflictResolverModel: null, - reasoningEffort: null, - description: "Assemble a single result lane for the mission and stop before external PR creation.", - }, - status: "finalizing", - executionComplete: true, - contractSatisfied: false, - blocked: false, - blockedReason: null, - summary: "Execution finished, but the closeout contract is still incomplete.", - detail: "risk notes", - resolverJobId: null, - integrationLaneId: null, - resultLaneId: fixture.laneId, - queueGroupId: null, - queueId: null, - activePrId: null, - waitReason: null, - proposalUrl: null, - prUrls: [], - reviewStatus: null, - mergeReadiness: null, - requirements: [], - warnings: [], - updatedAt: "2026-05-05T00:01:00.000Z", - startedAt: "2026-05-05T00:01:00.000Z", - completedAt: null, - }, - }, - }); - - (capturedCoordinator as any)?.deps.onRunFinalize?.({ - runId, - succeeded: true, - summary: "Risk notes: no remaining product risks identified.", - }); - - 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"); - expect(runView?.closeoutRequirements.find((entry) => entry.key === "risk_notes")?.status).toBe("present"); - } finally { - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("marks autogenerated closeout summaries incomplete for canceled runs", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Cancel before closeout completes.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "runtime-checks", - title: "Runtime checks", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation" }, - }, - ], - }); - const step = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }).steps[0]; - if (!step) throw new Error("Expected seeded step"); - fixture.db.run( - `update orchestrator_steps set status = 'canceled', updated_at = ? where id = ?`, - ["2026-05-05T00:01:00.000Z", step.id], - ); - fixture.db.run( - `update orchestrator_runs set status = 'canceled', 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 implementationSummary = view?.closeoutRequirements.find((entry) => entry.key === "implementation_summary"); - const finalOutcomeSummary = view?.closeoutRequirements.find((entry) => entry.key === "final_outcome_summary"); - - expect(implementationSummary?.status).toBe("incomplete"); - expect(finalOutcomeSummary?.status).toBe("incomplete"); - } finally { - fixture.dispose(); - } - }); - - it("persists a run-level autopilot cap from planner summary metadata in AI-first startup", async () => { - const fixture = await createFixture(); - try { - const plannerPlan = JSON.parse(VALID_PLANNER_PLAN) as Record; - const missionSummary = plannerPlan.missionSummary as Record; - missionSummary.parallelismCap = 6; - - const mission = fixture.missionService.create({ - prompt: "Implement orchestration updates with planner-provided cap.", - laneId: fixture.laneId, - plannerPlan: plannerPlan as any, - }); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - - expect(launched.started).toBeTruthy(); - const run = launched.started?.run; - expect(run).toBeTruthy(); - const metadata = (run?.metadata ?? {}) as Record; - const autopilot = (metadata.autopilot ?? {}) as Record; - - expect(metadata.maxParallelWorkers).toBe(6); - expect(autopilot.enabled).toBe(true); - expect(autopilot.executorKind).toBe("opencode"); - expect(autopilot.parallelismCap).toBe(6); - } finally { - fixture.dispose(); - } - }); - - it("persists disabled autopilot metadata for manual AI-first runs", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Run mission manually and verify autopilot metadata.", - laneId: fixture.laneId, - }); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - }); - - expect(launched.started).toBeTruthy(); - const run = launched.started?.run; - expect(run).toBeTruthy(); - const metadata = (run?.metadata ?? {}) as Record; - const autopilot = (metadata.autopilot ?? {}) as Record; - - expect(metadata.maxParallelWorkers).toBe(4); - expect(autopilot.enabled).toBe(false); - expect(autopilot.executorKind).toBe("manual"); - expect(autopilot.parallelismCap).toBe(4); - } finally { - fixture.dispose(); - } - }); - - it("does not activate runs when coordinator startup fails", async () => { - const fixture = await createFixture(); - let noRootService: ReturnType | null = null; - try { - fixture.aiOrchestratorService.dispose(); - noRootService = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - projectRoot: undefined - }); - - const mission = fixture.missionService.create({ - prompt: "Verify coordinator startup gating.", - laneId: fixture.laneId - }); - const launched = await noRootService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode" - }); - expect(launched.started).toBeTruthy(); - - const run = fixture.orchestratorService.listRuns({ missionId: mission.id })[0]; - expect(run?.status).toBe("paused"); - expect(fixture.orchestratorService.listAttempts({ runId: run?.id ?? "missing" })).toHaveLength(0); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("intervention_required"); - expect( - refreshed?.interventions.some( - (entry) => - entry.status === "open" - && entry.interventionType === "failed_step" - && String(entry.metadata?.reasonCode ?? "") === "coordinator_start_failed" - ) - ).toBe(true); - } finally { - noRootService?.dispose(); - fixture.dispose(); - } - }); - - it("pauses runs and blocks fallback runtime handling when coordinator is unavailable", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Ensure coordinator ownership on runtime events.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [{ stepKey: "guarded-step", title: "Guarded step", stepIndex: 0, executorKind: "opencode" }] - }); - fixture.db.run(`update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, [new Date().toISOString(), started.run.id]); - const step = fixture.orchestratorService.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId: started.run.id, - stepId: step.id, - attemptId: "attempt-without-coordinator", - at: new Date().toISOString(), - reason: "completed" - }); - - const updatedRun = fixture.orchestratorService.listRuns({ missionId: mission.id }).find((run) => run.id === started.run.id); - expect(updatedRun?.status).toBe("paused"); - expect(fixture.orchestratorService.listAttempts({ runId: started.run.id })).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("rehydrates a persisted coordinator chat session before pausing an active run", async () => { - const agentChatService = createMockAgentChatService({ - createSession: vi.fn().mockResolvedValue({ id: "coordinator-session-rehydrated" }), - runSessionTurn: vi.fn().mockResolvedValue({ outputText: "Coordinator rehydrated.", turnId: "runtime-turn-rehydrated" }), - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Recover coordinator ownership from a persisted chat thread.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [{ stepKey: "guarded-step", title: "Guarded step", stepIndex: 0, executorKind: "opencode" }] - }); - fixture.db.run(`update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, [new Date().toISOString(), started.run.id]); - fixture.db.run( - ` - insert into orchestrator_chat_threads( - id, project_id, mission_id, thread_type, title, run_id, - session_id, lane_id, status, unread_count, created_at, updated_at - ) values (?, 'proj-1', ?, 'coordinator', 'Orchestrator', ?, ?, ?, 'active', 0, ?, ?) - `, - [ - `mission:${mission.id}`, - mission.id, - started.run.id, - "coordinator-session-persisted", - fixture.laneId, - "2026-02-20T00:00:00.000Z", - "2026-02-20T00:00:00.000Z" - ] - ); - const step = fixture.orchestratorService.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId: started.run.id, - stepId: step.id, - attemptId: "attempt-with-persisted-coordinator", - at: new Date().toISOString(), - reason: "completed" - }); - - const updatedRun = fixture.orchestratorService.listRuns({ missionId: mission.id }).find((run) => run.id === started.run.id); - expect(updatedRun?.status).toBe("active"); - expect( - fixture.missionService.get(mission.id)?.interventions.some( - (entry) => entry.status === "open" && String(entry.metadata?.reasonCode ?? "") === "coordinator_unavailable" - ) ?? false - ).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("keeps explicit manual runs active when coordinator ownership is unavailable", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Exercise a manual mission graph without a coordinator session.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - runMode: "manual", - autopilot: { - enabled: false, - executorKind: "manual", - ownerId: "manual-owner", - parallelismCap: 1 - } - }, - steps: [{ stepKey: "manual-step", title: "Manual step", stepIndex: 0, executorKind: "manual" }] - }); - fixture.db.run(`update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, [new Date().toISOString(), started.run.id]); - const step = fixture.orchestratorService.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId: started.run.id, - stepId: step.id, - attemptId: "manual-attempt-without-coordinator", - at: new Date().toISOString(), - reason: "completed" - }); - - const updatedRun = fixture.orchestratorService.listRuns({ missionId: mission.id }).find((run) => run.id === started.run.id); - expect(updatedRun?.status).toBe("active"); - const refreshedMission = fixture.missionService.get(mission.id); - expect( - refreshedMission?.interventions.some( - (entry) => - entry.status === "open" - && String(entry.metadata?.reasonCode ?? "") === "coordinator_unavailable" - ) ?? false - ).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("ignores queued run updates while coordinator startup is still in progress", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Do not treat coordinator startup as coordinator loss.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1, - }, - }, - steps: [], - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-run-updated", - runId: started.run.id, - at: new Date().toISOString(), - reason: "bootstrapping", - }); - - const updatedRun = fixture.orchestratorService - .listRuns({ missionId: mission.id }) - .find((run) => run.id === started.run.id); - expect(updatedRun?.status).toBe("bootstrapping"); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.interventions ?? []).toHaveLength(0); - expect(fixture.orchestratorService.listAttempts({ runId: started.run.id })).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("propagates attempt token usage for attempt-updated completed events", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Track token propagation for completed attempt events.", - laneId: fixture.laneId - }); - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "token-step", title: "Token step", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual" }] - }); - fixture.orchestratorService.tick({ runId }); - const step = fixture.orchestratorService.listSteps(runId).find((entry) => entry.stepKey === "token-step"); - if (!step) throw new Error("Missing token-step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: step.id, - ownerId: "token-owner", - executorKind: "manual" - }); - fixture.db.run(`alter table orchestrator_attempts add column session_id text`); - fixture.db.run( - `update orchestrator_attempts set session_id = ?, executor_session_id = ? where id = ?`, - ["token-session-1", "token-session-1", attempt.id] - ); - fixture.db.run( - ` - insert into ai_usage_log( - id, timestamp, feature, provider, model, input_tokens, output_tokens, duration_ms, success, session_id - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - "usage-token-1", - new Date().toISOString(), - "orchestrator", - "claude", - "sonnet", - 70, - 50, - 200, - 1, - "token-session-1" - ] - ); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: step.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "completed" - }); - - const runRow = fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - const metadata = runRow?.metadata_json ? JSON.parse(runRow.metadata_json) : {}; - expect(Number(metadata.tokensConsumed ?? 0)).toBeGreaterThanOrEqual(120); - } finally { - fixture.dispose(); - } - }); - - it("tracks worker state through lifecycle on orchestrator events", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Build feature and test.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Before any attempts, no workers - expect(fixture.aiOrchestratorService.getWorkerStates({ runId })).toHaveLength(0); - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ - stepKey: "implement-changes", - title: "Implement requested changes", - stepIndex: 0, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - instructions: "Do the work", - modelId: "openai/gpt-5.3-codex", - } - }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - // Start attempt → fire event → worker should be "working" - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "started" - }); - - const workingStates = fixture.aiOrchestratorService.getWorkerStates({ runId }); - const targetWorking = workingStates.find((ws) => ws.attemptId === attempt.id); - expect(targetWorking).toBeTruthy(); - expect(targetWorking!.state).toBe("working"); - expect(targetWorking!.executorKind).toBe("manual"); - - // Complete attempt → fire event → worker should be "completed" - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded" - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "completed" - }); - - const completedStates = fixture.aiOrchestratorService.getWorkerStates({ runId }); - const targetCompleted = completedStates.find((ws) => ws.attemptId === attempt.id); - expect(targetCompleted).toBeTruthy(); - expect(targetCompleted!.state).toBe("completed"); - expect(targetCompleted!.completedAt).toBeTruthy(); - } finally { - fixture.dispose(); - } - }); - - it("pushes terminal sub-agent completion summaries to the parent attempt thread", async () => { - const injectMessageSpy = vi.spyOn(CoordinatorAgent.prototype, "injectMessage").mockImplementation(() => {}); - const injectEventSpy = vi.spyOn(CoordinatorAgent.prototype, "injectEvent").mockImplementation(() => {}); - let fixture: Awaited> | null = null; - try { - fixture = await createFixture(); - const mission = fixture.missionService.create({ - prompt: "Validate sub-agent completion rollups.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "parent-worker", - title: "Parent Worker", - stepIndex: 0, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { instructions: "Own the parent task." } - }, - { - stepKey: "child-worker", - title: "Child Worker", - stepIndex: 1, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - instructions: "Sub-agent child task.", - isSubAgent: true, - parentWorkerId: "parent-worker" - } - } - ] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const parentStep = graph.steps.find((step) => step.stepKey === "parent-worker"); - const childStep = graph.steps.find((step) => step.stepKey === "child-worker"); - if (!parentStep || !childStep) throw new Error("Expected parent/child steps"); - - const parentAttempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: parentStep.id, - ownerId: "parent-owner", - executorKind: "manual" - }); - const childAttempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: childStep.id, - ownerId: "child-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: childAttempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Implemented child task.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: true - } - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-step-updated", - runId, - stepId: childStep.id, - attemptId: childAttempt.id, - at: new Date().toISOString(), - reason: "attempt_completed" - }); - - const parentThread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((thread) => thread.attemptId === parentAttempt.id); - expect(parentThread).toBeTruthy(); - if (!parentThread) throw new Error("Expected parent thread"); - - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: parentThread.id, - limit: 200 - }); - const rollups = messages.filter((entry) => - entry.role === "agent" - && String(entry.metadata?.source ?? "") === "subagent_result_rollup" - && entry.content.includes("Sub-agent 'Child Worker' completed (succeeded): Implemented child task.") - ); - expect(rollups).toHaveLength(1); - } finally { - fixture?.dispose(); - injectEventSpy.mockRestore(); - injectMessageSpy.mockRestore(); - } - }); - - it("deduplicates sub-agent completion rollups when completion events are replayed", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Ensure sub-agent completion rollups are deduplicated.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "parent-worker", - title: "Parent Worker", - stepIndex: 0, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { instructions: "Parent." } - }, - { - stepKey: "child-worker", - title: "Child Worker", - stepIndex: 1, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - instructions: "Child.", - isSubAgent: true, - parentWorkerId: "parent-worker" - } - } - ] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const parentStep = graph.steps.find((step) => step.stepKey === "parent-worker"); - const childStep = graph.steps.find((step) => step.stepKey === "child-worker"); - if (!parentStep || !childStep) throw new Error("Expected parent/child steps"); - - const parentAttempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: parentStep.id, - ownerId: "parent-owner", - executorKind: "manual" - }); - const childAttempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: childStep.id, - ownerId: "child-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: childAttempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Child complete.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: true - } - }); - - const completionEvent = { - type: "orchestrator-attempt-updated" as const, - runId, - stepId: childStep.id, - attemptId: childAttempt.id, - at: new Date().toISOString(), - reason: "completed" as const - }; - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent(completionEvent); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent(completionEvent); - - const parentThread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((thread) => thread.attemptId === parentAttempt.id); - expect(parentThread).toBeTruthy(); - if (!parentThread) throw new Error("Expected parent thread"); - - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: parentThread.id, - limit: 200 - }); - const rollups = messages.filter((entry) => - entry.role === "agent" - && String(entry.metadata?.source ?? "") === "subagent_result_rollup" - && entry.content.includes("Sub-agent 'Child Worker' completed (succeeded): Child complete.") - ); - expect(rollups).toHaveLength(1); - } finally { - fixture.dispose(); - } - }); - - it("tracks failed worker state", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "A task that will fail.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ - stepKey: "implement-changes", - title: "Implement requested changes", - stepIndex: 0, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - instructions: "Do the work", - modelId: "openai/gpt-5.3-codex", - } - }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "deterministic", - errorMessage: "Test failure" - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "failed" - }); - - const states = fixture.aiOrchestratorService.getWorkerStates({ runId }); - expect(states.length).toBe(1); - expect(states[0].state).toBe("failed"); - } finally { - fixture.aiOrchestratorService.dispose(); - } - }); - - it("gracefully cancels runs by notifying and shutting down active worker sessions before hard cancel", async () => { - const sendMessage = vi.fn().mockResolvedValue(undefined); - const interrupt = vi.fn().mockResolvedValue(undefined); - const disposeSession = vi.fn().mockResolvedValue(undefined); - const fixture = await createFixture({ - agentChatService: createMockAgentChatService({ - sendMessage, - interrupt, - dispose: disposeSession - }) - }); - try { - const mission = fixture.missionService.create({ - prompt: "Cancel the run after worker startup.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [{ stepKey: "implement", title: "Implement", stepIndex: 0 }] - }); - const runId = started.run.id; - - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - ["worker-session-cancel-1", attempt.id] - ); - - const canceled = await fixture.aiOrchestratorService.cancelRunGracefully({ - runId, - reason: "Canceled from test." - }); - expect(canceled.status).toBe("canceled"); - expect(sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "worker-session-cancel-1", - text: expect.stringContaining("Run cancellation requested") - }) - ); - expect(interrupt).toHaveBeenCalledWith({ sessionId: "worker-session-cancel-1" }); - expect(disposeSession).toHaveBeenCalledWith({ sessionId: "worker-session-cancel-1" }); - - const refreshed = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const refreshedAttempt = refreshed.attempts.find((entry) => entry.id === attempt.id); - expect(refreshedAttempt?.status).toBe("canceled"); - } finally { - fixture.dispose(); - } - }); - - it("still force-cancels runs when graceful shutdown worker calls fail", async () => { - const sendMessage = vi.fn().mockRejectedValue(new Error("send failed")); - const interrupt = vi.fn().mockRejectedValue(new Error("interrupt failed")); - const disposeSession = vi.fn().mockRejectedValue(new Error("dispose failed")); - const fixture = await createFixture({ - agentChatService: createMockAgentChatService({ - sendMessage, - interrupt, - dispose: disposeSession - }) - }); - try { - const mission = fixture.missionService.create({ - prompt: "Cancel run even if worker shutdown errors.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [{ stepKey: "implement", title: "Implement", stepIndex: 0 }] - }); - const runId = started.run.id; - - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - ["worker-session-cancel-2", attempt.id] - ); - - const canceled = await fixture.aiOrchestratorService.cancelRunGracefully({ - runId, - reason: "Cancel with failures." - }); - expect(canceled.status).toBe("canceled"); - expect(sendMessage).toHaveBeenCalled(); - expect(interrupt).toHaveBeenCalled(); - expect(disposeSession).toHaveBeenCalled(); - } finally { - fixture.dispose(); - } - }); - - it("shuts down the live coordinator on cancel and suppresses late coordinator writes", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const shutdownSpy = vi.spyOn(CoordinatorAgent.prototype, "shutdown"); - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Cancel while only the coordinator is active.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - const beforeCancelChatCount = fixture.aiOrchestratorService.getChat({ missionId: mission.id }).length; - const canceled = await fixture.aiOrchestratorService.cancelRunGracefully({ - runId, - reason: "User canceled from Missions UI.", - }); - expect(canceled.status).toBe("canceled"); - expect(shutdownSpy).toHaveBeenCalled(); - - const coordinatorThread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((thread) => thread.threadType === "coordinator"); - expect(coordinatorThread?.status).toBe("closed"); - - const afterCancelMessages = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(afterCancelMessages.length).toBeGreaterThan(beforeCancelChatCount); - const afterCancelCount = afterCancelMessages.length; - - (capturedCoordinator as any)?.deps.onCoordinatorMessage?.("late plain coordinator message"); - (capturedCoordinator as any)?.deps.onCoordinatorEvent?.({ - type: "text", - text: "late structured coordinator message", - turnId: "turn-late", - itemId: "late-1", - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-run-updated", - runId, - at: new Date().toISOString(), - reason: "status_updated", - } as any); - - await new Promise((resolve) => setTimeout(resolve, 25)); - - const finalMessages = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(finalMessages).toHaveLength(afterCancelCount); - expect(finalMessages.some((entry) => entry.content.includes("late plain coordinator message"))).toBe(false); - expect(finalMessages.some((entry) => entry.content.includes("late structured coordinator message"))).toBe(false); - - const openCoordinatorInterventions = fixture.missionService - .get(mission.id) - ?.interventions.filter( - (entry) => entry.status === "open" && /coordinator/i.test(entry.title), - ) ?? []; - expect(openCoordinatorInterventions).toHaveLength(0); - } finally { - shutdownSpy.mockRestore(); - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("shuts down the live coordinator when a hard cap pause is triggered", async () => { - let capturedCoordinator: CoordinatorAgent | null = null; - const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; - const captureSpy = vi - .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") - .mockImplementation(function (this: CoordinatorAgent) { - capturedCoordinator = this; - return originalEnsurePlannerLaunchTrackerStep.call(this); - }); - const shutdownSpy = vi.spyOn(CoordinatorAgent.prototype, "shutdown"); - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Pause this mission on budget hard cap.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - expect(capturedCoordinator).toBeTruthy(); - - (capturedCoordinator as any)?.deps.onHardCapTriggered?.("Budget hard cap reached during planning."); - - expect(shutdownSpy).toHaveBeenCalled(); - const runGraph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 10 }); - expect(runGraph.run.status).toBe("paused"); - } finally { - shutdownSpy.mockRestore(); - captureSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("cancels runs cleanly when both the coordinator and worker sessions are active", async () => { - const sendMessage = vi.fn().mockResolvedValue(undefined); - const interrupt = vi.fn().mockResolvedValue(undefined); - const disposeSession = vi.fn().mockResolvedValue(undefined); - const shutdownSpy = vi.spyOn(CoordinatorAgent.prototype, "shutdown"); - const fixture = await createFixture({ - agentChatService: createMockAgentChatService({ - sendMessage, - interrupt, - dispose: disposeSession, - }), - }); - try { - const mission = fixture.missionService.create({ - prompt: "Cancel after coordinator startup and worker launch.", - laneId: fixture.laneId, - }); - setMissionPlanningMode(fixture.db, mission.id, "auto"); - - const launched = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launched.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement", title: "Implement", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual" }], - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const readyStep = graph.steps.find((step) => step.status === "ready" && step.stepKey === "implement"); - if (!readyStep) throw new Error("Expected implementation step to be ready"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual", - }); - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - ["worker-session-cancel-3", attempt.id], - ); - - const canceled = await fixture.aiOrchestratorService.cancelRunGracefully({ - runId, - reason: "Cancel both coordinator and workers.", - }); - - expect(canceled.status).toBe("canceled"); - expect(shutdownSpy).toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "worker-session-cancel-3" })); - expect(interrupt).toHaveBeenCalledWith({ sessionId: "worker-session-cancel-3" }); - expect(disposeSession).toHaveBeenCalledWith({ sessionId: "worker-session-cancel-3" }); - } finally { - shutdownSpy.mockRestore(); - fixture.dispose(); - } - }); - - it("cleans up mission lanes through explicit team resource cleanup API", async () => { - const archiveLane = vi.fn().mockImplementation(({ laneId }: { laneId: string }) => { - if (laneId === "lane-cleanup-b") { - throw new Error("Lane not found"); - } - }); - const fixture = await createFixture({ - laneService: { - archive: archiveLane - } as any - }); - try { - const mission = fixture.missionService.create({ - prompt: "Archive lanes after mission cancellation.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { stepKey: "lane-a", title: "Lane A", stepIndex: 0 }, - { stepKey: "lane-b", title: "Lane B", stepIndex: 1 } - ] - }); - const runId = started.run.id; - const laneInsertTime = new Date().toISOString(); - - fixture.db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - "lane-cleanup-a", - fixture.projectId, - "Cleanup A", - null, - "worktree", - "main", - "feature/cleanup-a", - fixture.projectRoot, - null, - 0, - null, - null, - null, - null, - "active", - laneInsertTime, - null - ] - ); - fixture.db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - "lane-cleanup-b", - fixture.projectId, - "Cleanup B", - null, - "worktree", - "main", - "feature/cleanup-b", - fixture.projectRoot, - null, - 0, - null, - null, - null, - null, - "active", - laneInsertTime, - null - ] - ); - - const graph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const stepA = graph.steps.find((step) => step.stepKey === "lane-a"); - const stepB = graph.steps.find((step) => step.stepKey === "lane-b"); - if (!stepA || !stepB) throw new Error("Expected lane steps"); - - fixture.db.run(`update orchestrator_steps set lane_id = ? where id = ?`, ["lane-cleanup-a", stepA.id]); - fixture.db.run(`update orchestrator_steps set lane_id = ? where id = ?`, ["lane-cleanup-b", stepB.id]); - - const result = await fixture.aiOrchestratorService.cleanupTeamResources({ - missionId: mission.id, - runId, - cleanupLanes: true - }); - - expect(result.runId).toBe(runId); - expect(result.laneIds).toEqual(expect.arrayContaining(["lane-cleanup-a", "lane-cleanup-b"])); - expect(result.lanesArchived).toEqual(expect.arrayContaining(["lane-cleanup-a"])); - expect(result.lanesSkipped).toEqual(expect.arrayContaining(["lane-cleanup-b"])); - expect(result.laneErrors).toHaveLength(0); - expect(archiveLane).toHaveBeenCalledWith({ laneId: "lane-cleanup-a" }); - expect(archiveLane).toHaveBeenCalledWith({ laneId: "lane-cleanup-b" }); - } finally { - fixture.dispose(); - } - }); - - it("dispatches TaskCompleted hooks on step completion runtime events", async () => { - const hookCommandRunner = vi.fn().mockResolvedValue({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 7, - stdout: "task done", - stderr: "", - spawnError: null - }); - const fixture = await createFixture({ - orchestratorConfig: { - hooks: { - TaskCompleted: { - command: "echo task-completed", - timeoutMs: 2500 - } - } - }, - hookCommandRunner - }); - try { - const mission = fixture.missionService.create({ - prompt: "Dispatch completion hooks for finished steps.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded" - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-step-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "attempt_completed" - }); - - await waitFor(() => hookCommandRunner.mock.calls.length === 1); - const [hookCall] = hookCommandRunner.mock.calls[0] as [Record>]; - expect(hookCall.command).toBe("echo task-completed"); - expect(hookCall.timeoutMs).toBe(2500); - expect((hookCall.env as Record).ADE_HOOK_EVENT).toBe("TaskCompleted"); - expect((hookCall.env as Record).ADE_HOOK_RUN_ID).toBe(runId); - expect((hookCall.env as Record).ADE_HOOK_STEP_ID).toBe(readyStep.id); - - await waitFor(() => { - const events = fixture.orchestratorService - .listRuntimeEvents({ runId, eventTypes: ["progress"], limit: 200 }) - .filter((event) => { - const payload = event.payload as Record | null; - return payload?.source === "orchestrator_hook" && payload?.hookEvent === "TaskCompleted"; - }); - const phases = events.map((event) => (event.payload as Record | null)?.phase); - return phases.includes("started") && phases.includes("succeeded"); - }); - } finally { - fixture.dispose(); - } - }); - - it("dispatches TeammateIdle hook only on transition into waiting input", async () => { - const hookCommandRunner = vi.fn().mockResolvedValue({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 5, - stdout: "idle", - stderr: "", - spawnError: null - }); - const fixture = await createFixture({ - orchestratorConfig: { - hooks: { - TeammateIdle: { - command: "echo teammate-idle", - timeoutMs: 2200 - } - } - }, - hookCommandRunner - }); - try { - const mission = fixture.missionService.create({ - prompt: "Track worker waiting-input transitions.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - const sessionId = "session-idle-hook-1"; - fixture.db.run( - `update orchestrator_attempts set executor_kind = 'opencode', executor_session_id = ? where id = ?`, - [sessionId, attempt.id] - ); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "started" - }); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "waiting-input", - lastOutputPreview: "Need approval to continue.", - at: new Date().toISOString() - }); - await waitFor(() => hookCommandRunner.mock.calls.length === 1); - const [hookCall] = hookCommandRunner.mock.calls[0] as [Record>]; - expect(hookCall.command).toBe("echo teammate-idle"); - expect((hookCall.env as Record).ADE_HOOK_EVENT).toBe("TeammateIdle"); - expect((hookCall.env as Record).ADE_HOOK_ATTEMPT_ID).toBe(attempt.id); - expect((hookCall.env as Record).ADE_HOOK_SESSION_ID).toBe(sessionId); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "waiting-input", - lastOutputPreview: "Need approval to continue.", - at: new Date(Date.now() + 1000).toISOString() - }); - await new Promise((resolve) => setTimeout(resolve, 80)); - expect(hookCommandRunner).toHaveBeenCalledTimes(1); - } finally { - fixture.dispose(); - } - }); - - it("keeps runtime handling alive when hook execution fails", async () => { - const logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - }; - const hookCommandRunner = vi.fn().mockRejectedValue(new Error("hook transport unavailable")); - const fixture = await createFixture({ - orchestratorConfig: { - hooks: { - TaskCompleted: { - command: "echo task-hook-fails" - } - } - }, - hookCommandRunner, - logger - }); - try { - const mission = fixture.missionService.create({ - prompt: "Do not crash if runtime hooks fail.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - expect(() => { - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-step-updated", - runId, - stepId: readyStep.id, - at: new Date().toISOString(), - reason: "attempt_completed" - }); - }).not.toThrow(); - - await waitFor(() => - logger.warn.mock.calls.some((entry) => entry[0] === "ai_orchestrator.hook_execution_failed") - ); - expect(() => fixture.aiOrchestratorService.getWorkerStates({ runId })).not.toThrow(); - - const hookEvents = fixture.orchestratorService - .listRuntimeEvents({ runId, eventTypes: ["progress"], limit: 200 }) - .filter((event) => { - const payload = event.payload as Record | null; - return payload?.source === "orchestrator_hook" && payload?.hookEvent === "TaskCompleted"; - }); - expect(hookEvents.some((event) => (event.payload as Record | null)?.phase === "failed")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("does not enter a run body while another health sweep owns it", async () => { - const fixture = await createFixture(); - let releaseFirstSweep: () => void = () => {}; - const firstSweepGate = new Promise((resolve) => { - releaseFirstSweep = resolve; - }); - try { - const mission = fixture.missionService.create({ - prompt: "Verify overlapping health sweeps keep single ownership.", - laneId: fixture.laneId - }); - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - // Install the gated reconcile patch BEFORE creating the tracked-session - // row that triggers reconciliation. The aiOrchestratorService runs a - // periodic interval sweep (HEALTH_SWEEP_INTERVAL_MS=30s) that can fire - // between DB setup and patching; if it does, it would consume the - // running attempt via the unpatched reconcile and the test's - // "overlap-owner" sweep would then find nothing to reconcile, leaving - // firstReconcileEntered unresolved. - const originalReconcile = fixture.orchestratorService.onTrackedSessionEnded.bind(fixture.orchestratorService); - let reconcileCalls = 0; - let resolveFirstReconcile!: () => void; - const firstReconcileEntered = new Promise((resolve) => { - resolveFirstReconcile = resolve; - }); - fixture.orchestratorService.onTrackedSessionEnded = (async (args: any) => { - reconcileCalls += 1; - if (reconcileCalls === 1) resolveFirstReconcile(); - await firstSweepGate; - return await originalReconcile(args); - }) as typeof fixture.orchestratorService.onTrackedSessionEnded; - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - const sessionId = "session-ended-overlap"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - fixture.db.run( - ` - insert 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', ?, ?, 0, '', null, null, 'ended', null, null, 'codex-orchestrated', null, ?) - `, - [ - sessionId, - fixture.laneId, - "2000-01-01T00:00:00.000Z", - "2000-01-01T00:00:01.000Z", - "2000-01-01T00:00:00.000Z", - ] - ); - - const firstSweep = fixture.aiOrchestratorService.runHealthSweep("overlap-owner"); - // Wait deterministically for the gated reconcile to enter, with a hard - // ceiling well under the test timeout. CI runners can be very contended; - // a Promise-based wait is more reliable than polling. - await Promise.race([ - firstReconcileEntered, - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`first reconcile did not enter within 280s (reconcileCalls=${reconcileCalls})`)), - 280_000, - ), - ), - ]); - expect(reconcileCalls).toBe(1); - const overlappedSweep = await fixture.aiOrchestratorService.runHealthSweep("overlap-contender"); - - expect(overlappedSweep.sweeps).toBe(0); - expect(reconcileCalls).toBe(1); - - releaseFirstSweep(); - await firstSweep; - } finally { - releaseFirstSweep(); - fixture.dispose(); - } - }, 300_000); - - it("skips background health sweeps for runs blocked on open interventions", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Pause background churn while the user resolves a blocker.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "manual_input", - title: "Need direction", - body: "Choose the next recovery path.", - metadata: { - runId, - canProceedWithoutAnswer: false, - }, - }); - - const backgroundSweep = await fixture.aiOrchestratorService.runHealthSweep("interval"); - expect(backgroundSweep.sweeps).toBe(0); - - const explicitSweep = await fixture.aiOrchestratorService.runHealthSweep("chat_status"); - expect(explicitSweep.sweeps).toBe(1); - } finally { - fixture.dispose(); - } - }); - - it("keeps running attempts alive when tracked sessions show recent output activity", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Do long-running work while continuously streaming output.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-active-1"; - const transcriptPath = path.join(fixture.projectRoot, ".ade", "transcripts", `${sessionId}.log`); - fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); - fs.writeFileSync(transcriptPath, "still producing output\n", "utf8"); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ?, - started_at = ?, - created_at = ? - where id = ? - `, - [sessionId, "2000-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z", attempt.id] - ); - - fixture.db.run( - ` - insert 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, ?) - `, - [ - sessionId, - fixture.laneId, - "2026-02-20T00:00:00.000Z", - transcriptPath, - new Date().toISOString() - ] - ); - - const sweep = await fixture.aiOrchestratorService.runHealthSweep("active_output"); - expect(sweep.staleRecovered).toBe(0); - - const refreshedGraph = fixture.orchestratorService.getRunGraph({ runId }); - const refreshedAttempt = refreshedGraph.attempts.find((entry) => entry.id === attempt.id); - expect(refreshedAttempt?.status).toBe("running"); - } finally { - fixture.dispose(); - } - }); - - it("recovers running attempts with tracked sessions that go silent", async () => { - vi.useRealTimers(); - const fixture = await createFixture({ - aiIntegrationService: createStagnationRecoveryAiIntegrationService() - }); - try { - const mission = fixture.missionService.create({ - prompt: "Recover no-output workers before they block the mission forever.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-silent-1"; - const transcriptPath = path.join(fixture.projectRoot, ".ade", "transcripts", `${sessionId}.log`); - fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); - fs.writeFileSync(transcriptPath, "", "utf8"); - const old = new Date("2000-01-01T00:00:00.000Z"); - fs.utimesSync(transcriptPath, old, old); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ?, - started_at = ?, - created_at = ? - where id = ? - `, - [sessionId, "2000-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z", attempt.id] - ); - - fixture.db.run( - ` - insert 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, ?) - `, - [ - sessionId, - fixture.laneId, - "2026-02-20T00:00:00.000Z", - transcriptPath, - "2000-01-01T00:00:00.000Z" - ] - ); - - const sweep = await fixture.aiOrchestratorService.runHealthSweep("silent_output"); - expect(sweep.staleRecovered).toBeGreaterThanOrEqual(1); - - const refreshedGraph = fixture.orchestratorService.getRunGraph({ runId }); - const refreshedAttempt = refreshedGraph.attempts.find((entry) => entry.id === attempt.id); - expect(refreshedAttempt?.status).toBe("failed"); - expect(refreshedAttempt?.errorMessage ?? "").toContain("stagnating"); - } finally { - fixture.dispose(); - } - }); - - it("does not open failed-step intervention when failure already queued a retry", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Implement endpoint and run verification checks.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually with retryLimit > 0 so the first failure queues a retry - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", retryLimit: 2, metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "transient", - errorMessage: "Temporary CI outage" - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "failed" - }); - - const refreshedMission = fixture.missionService.get(mission.id); - const failedStepInterventions = refreshedMission?.interventions.filter((item) => item.interventionType === "failed_step") ?? []; - expect(failedStepInterventions).toHaveLength(0); - - const chat = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(chat.some((entry) => entry.content.includes("Retry scheduled"))).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("auto-resolves exhausted planning interventions when a recovery planning step succeeds", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Recover a failed planning step with a follow-up planning worker.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "planner-primary", - title: "Primary planner", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - retryLimit: 0, - metadata: { - instructions: "Plan the work.", - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - readOnlyExecution: true, - } - } - ] - }); - fixture.orchestratorService.tick({ runId }); - let graph = fixture.orchestratorService.getRunGraph({ runId }); - const failedPlanner = graph.steps.find((step) => step.stepKey === "planner-primary" && step.status === "ready"); - if (!failedPlanner) throw new Error("Expected primary planner step to be ready"); - - const failedAttempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: failedPlanner.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: failedAttempt.id, - status: "failed", - errorClass: "startup_failure", - errorMessage: "Planning worker exited before producing any assistant or tool activity.", - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: failedPlanner.id, - attemptId: failedAttempt.id, - at: new Date().toISOString(), - reason: "failed" - }); - - let refreshedMission = fixture.missionService.get(mission.id); - const openPlanningIntervention = refreshedMission?.interventions.find((entry) => - entry.status === "open" - && entry.interventionType === "failed_step" - && entry.body.includes("Planning worker exited before producing any assistant or tool activity.") - ); - expect(openPlanningIntervention).toBeTruthy(); - expect(refreshedMission?.status).toBe("intervention_required"); - - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "planner-recovery", - title: "Recovery planner", - stepIndex: 1, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { - instructions: "Recover the planning pass and report back.", - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - readOnlyExecution: true, - } - } - ] - }); - fixture.orchestratorService.tick({ runId }); - graph = fixture.orchestratorService.getRunGraph({ runId }); - const recoveryPlanner = graph.steps.find((step) => step.stepKey === "planner-recovery" && step.status === "ready"); - if (!recoveryPlanner) throw new Error("Expected recovery planner step to be ready"); - - const recoveryAttempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: recoveryPlanner.id, - ownerId: "test-owner", - executorKind: "manual" - }); - fixture.db.run( - "update orchestrator_steps set metadata_json = ? where id = ? and project_id = ?", - [ - JSON.stringify({ - instructions: "Recover the planning pass and report back.", - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - readOnlyExecution: true, - lastResultReport: { - summary: "Recovered the plan and reported the next steps.", - plan: { - markdown: "# Recovery plan\n\n- Re-run planning\n- Persist the canonical plan\n", - }, - }, - }), - recoveryPlanner.id, - fixture.projectId, - ], - ); - await fixture.orchestratorService.completeAttempt({ - attemptId: recoveryAttempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Recovered the plan and reported the next steps.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: recoveryPlanner.id, - attemptId: recoveryAttempt.id, - at: new Date().toISOString(), - reason: "succeeded" - }); - - refreshedMission = fixture.missionService.get(mission.id); - const resolvedPlanningIntervention = refreshedMission?.interventions.find((entry) => entry.id === openPlanningIntervention?.id); - expect(resolvedPlanningIntervention?.status).toBe("resolved"); - expect(refreshedMission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("auto-resolves stale coordinator availability interventions once the coordinator is healthy again", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Close stale coordinator availability interventions after recovery.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - const intervention = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: "Coordinator unavailable", - body: "Coordinator agent is not available for this run.", - requestedAction: "Resume after coordinator runtime is healthy.", - metadata: { - runId, - reasonCode: "coordinator_unavailable", - }, - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-run-updated", - runId, - at: new Date().toISOString(), - reason: "heartbeat", - } as any); - - const refreshedMission = fixture.missionService.get(mission.id); - const refreshedIntervention = refreshedMission?.interventions.find((entry) => entry.id === intervention.id); - expect(refreshedIntervention?.status).toBe("resolved"); - - const resolvedEvent = fixture.orchestratorService.listRuntimeEvents({ - runId, - eventTypes: ["intervention_resolved"], - limit: 10, - }).find((entry) => - entry.eventType === "intervention_resolved" - && String((entry.payload as Record | null)?.interventionId ?? "") === intervention.id - ); - expect(resolvedEvent).toBeTruthy(); - } finally { - fixture.dispose(); - } - }); - - it("auto-resolves stale coordinator runtime failure interventions once the coordinator is healthy again", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Close stale coordinator runtime failure interventions after recovery.", - laneId: fixture.laneId, - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - const intervention = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "unrecoverable_error", - title: "Coordinator stopped unexpectedly", - body: "ADE paused the run because the coordinator stopped unexpectedly.", - requestedAction: "Inspect the coordinator failure, then resume the run.", - metadata: { - runId, - reasonCode: "coordinator_runtime_failed", - coordinatorFailureHandled: true, - }, - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-run-updated", - runId, - at: new Date().toISOString(), - reason: "heartbeat", - } as any); - - const refreshedMission = fixture.missionService.get(mission.id); - const refreshedIntervention = refreshedMission?.interventions.find((entry) => entry.id === intervention.id); - expect(refreshedIntervention?.status).toBe("resolved"); - - const resolvedEvent = fixture.orchestratorService.listRuntimeEvents({ - runId, - eventTypes: ["intervention_resolved"], - limit: 10, - }).find((entry) => - entry.eventType === "intervention_resolved" - && String((entry.payload as Record | null)?.interventionId ?? "") === intervention.id - ); - expect(resolvedEvent).toBeTruthy(); - } finally { - fixture.dispose(); - } - }); - - it("keeps coordinator availability interventions open when no live coordinator exists", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Do not clear coordinator blockers without a live coordinator.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - }); - const runId = started.run.id; - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "manual-check", - title: "Manual check", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - }, - ], - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.stepKey === "manual-check" && step.status === "ready"); - if (!readyStep) throw new Error("Expected manual-check step to be ready"); - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - const intervention = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: "Coordinator unavailable", - body: "Coordinator agent is not available for this run.", - requestedAction: "Resume after coordinator runtime is healthy.", - metadata: { - runId, - stepId: readyStep.id, - attemptId: attempt.id, - reasonCode: "coordinator_unavailable", - }, - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-step-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "attempt_completed", - } as any); - - const refreshedMission = fixture.missionService.get(mission.id); - const refreshedIntervention = refreshedMission?.interventions.find((entry) => entry.id === intervention.id); - expect(refreshedIntervention?.status).toBe("open"); - - const resolvedEvents = fixture.orchestratorService.listRuntimeEvents({ - runId, - eventTypes: ["intervention_resolved"], - limit: 10, - }); - expect( - resolvedEvents.some((entry) => - String((entry.payload as Record | null)?.interventionId ?? "") === intervention.id - ) - ).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("prefers mission launch failures over later coordinator-unavailable interventions in run view", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Show the root launch failure first.", - laneId: fixture.laneId, - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - }); - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "unrecoverable_error", - title: "Mission launch failed", - body: "ADE could not finish mission launch during coordinator startup.", - requestedAction: "Fix launch and restart.", - metadata: { - runId: started.run.id, - reasonCode: "mission_launch_failed", - failureStage: "coordinator_startup", - failureStageLabel: "coordinator startup", - rootError: "Wrong API use : tried to bind a value of an unknown type (undefined).", - coordinatorState: "not_started", - }, - }); - fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: "Coordinator unavailable", - body: "Coordinator agent is not available for this run.", - requestedAction: "Resume after coordinator runtime is healthy.", - metadata: { - runId: started.run.id, - reasonCode: "coordinator_unavailable", - }, - }); - - const runView = await fixture.aiOrchestratorService.getRunView({ - missionId: mission.id, - runId: started.run.id, - }); - - expect(runView?.haltReason?.title).toBe("Mission launch failed"); - expect(runView?.haltReason?.detail).toContain("Launch failed during coordinator startup"); - expect(runView?.haltReason?.detail).toContain("Wrong API use"); - expect(runView?.haltReason?.severity).toBe("error"); - } finally { - fixture.dispose(); - } - }); - - it("reconciles running attempts when tracked sessions already ended", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Recover attempts when tracked worker sessions end.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - ["session-ended-1", attempt.id] - ); - - fixture.db.run( - ` - insert 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 - ) values (?, ?, null, 1, 'Worker', ?, ?, 0, ?, null, null, 'completed', null, null, 'codex-orchestrated', null) - `, - [ - "session-ended-1", - fixture.laneId, - "2026-02-20T00:00:00.000Z", - "2026-02-20T00:05:00.000Z", - path.join(fixture.projectRoot, ".ade", "transcripts", "session-ended-1.log") - ] - ); - fs.mkdirSync(path.join(fixture.projectRoot, ".ade", "transcripts"), { recursive: true }); - fs.writeFileSync( - path.join(fixture.projectRoot, ".ade", "transcripts", "session-ended-1.log"), - "Worker completed the requested changes and exited cleanly.\n", - "utf8" - ); - - const sweep = await fixture.aiOrchestratorService.runHealthSweep("session_ended_test"); - expect(sweep.staleRecovered).toBeGreaterThanOrEqual(1); - - const refreshedGraph = fixture.orchestratorService.getRunGraph({ runId }); - const refreshedAttempt = refreshedGraph.attempts.find((entry) => entry.id === attempt.id); - expect(refreshedAttempt?.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("opens manual-input interventions from runtime waiting-input signals", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Keep workers moving and request help when blocked.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-waiting-input-1"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "waiting-input", - lastOutputPreview: "Need your input: choose option A or B before proceeding.", - at: new Date().toISOString() - }); - - await waitFor(() => { - const refreshed = fixture.missionService.get(mission.id); - return Boolean( - refreshed?.interventions.some( - (entry) => - entry.status === "open" && - entry.interventionType === "manual_input" && - String(entry.metadata?.attemptId ?? "") === attempt.id - ) - ); - }); - - const refreshedMission = fixture.missionService.get(mission.id); - const waitingIntervention = refreshedMission?.interventions.find( - (entry) => - entry.status === "open" - && entry.interventionType === "manual_input" - && String(entry.metadata?.attemptId ?? "") === attempt.id - ); - expect( - refreshedMission?.interventions.some( - (entry) => - entry.status === "open" && - entry.interventionType === "manual_input" && - String(entry.metadata?.attemptId ?? "") === attempt.id - ) - ).toBe(true); - expect(String(waitingIntervention?.metadata?.threadId ?? "")).toBe(`question:${attempt.id}`); - expect(String(waitingIntervention?.metadata?.messageId ?? "")).toContain(`question:${attempt.id}:`); - - const questionEvent = fixture.orchestratorService - .listRuntimeEvents({ - attemptId: attempt.id, - eventTypes: ["question"], - limit: 5 - }) - .find((entry) => entry.eventType === "question"); - expect(questionEvent?.questionLink?.threadId).toBe(`question:${attempt.id}`); - expect(questionEvent?.questionLink?.messageId.startsWith(`question:${attempt.id}:`)).toBe(true); - expect(questionEvent?.questionLink?.replyTo).toBeNull(); - - const states = fixture.aiOrchestratorService.getWorkerStates({ runId }); - const tracked = states.find((entry) => entry.attemptId === attempt.id); - expect(tracked?.state).toBe("waiting_input"); - } finally { - fixture.dispose(); - } - }); - - it("turns readable runtime previews into worker chat updates without duplicating identical signals", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Surface worker progress in chat.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-worker-progress-chat-1"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - const preview = "- Updated [AppShell.tsx](/Users/admin/Projects/ADE/apps/desktop/src/renderer/components/app/AppShell.tsx)"; - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "running", - lastOutputPreview: preview, - at: new Date().toISOString() - }); - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "running", - lastOutputPreview: preview, - at: new Date(Date.now() + 1000).toISOString() - }); - - await waitFor(() => { - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - if (!thread) return false; - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread.id - }); - return messages.filter((entry) => entry.role === "worker" && entry.content === "Updated AppShell.tsx.").length === 1; - }); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - if (!thread) throw new Error("Expected worker thread"); - - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread.id - }); - expect(messages.filter((entry) => entry.role === "worker" && entry.content === "Updated AppShell.tsx.")).toHaveLength(1); - } finally { - fixture.dispose(); - } - }); - - it("ignores noisy runtime previews that are just prompt/bootstrap or tool payload leakage", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Ignore noisy worker runtime previews in chat.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-worker-progress-chat-noise"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - const noisyPreviews = [ - "You are an ADE orchestrator worker executing step \"implement-changes\".", - "Mission goal: Ignore noisy worker runtime previews in chat.", - "Mission Plan:", - "Referenced docs: README.md (abc123), docs/architecture.md (def456)", - "tool ade.report_result({\"workerId\":\"worker_1\"})", - "\"text\": \"{\\n \\\"ok\\\": true }\"", - "admin@Mac test1-30b1aa3d %", - "-p \"$(cat '/Users/admin/Projects/ADE/.ade/orchestrator/worker-prompts/worker-123.txt')\"", - "cp '/tmp/worker-123.json' '.ade-worker-123.json' && exec codex --model gpt-5.3-codex", - "12f2b.txt')\"", - "ADE_MISSION_ID='mission-1' exec claude --model 'sonnet' --permission-mode 'default'", - "orchestrator/worker-prompts/worker-ce33e94c-b964-42c9-9127-dfdeb6853d36", - "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.legacy-cli/get-codex-token.sh", - "/Users/admin/.legacy-cli/completions/legacy.zsh:3803: command not found: compdef", - "apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts:428: const result =", - "- `.ade/step-output-worker_validate-test-tab_1772818763484.md` — structured step output for orchestration", - "\"type\": \"text\",", - "+
", - "- apps/desktop/src/renderer/components", - "\"missionId", - "] as cons", - "EOF\" in /Users/admin/Projects/ADE/.ade/worktrees/test-11-157a722d", - ]; - - for (const preview of noisyPreviews) { - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "running", - lastOutputPreview: preview, - at: new Date().toISOString() - }); - } - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - - if (!thread) { - expect(thread).toBeUndefined(); - } else { - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread.id - }); - expect( - messages.filter((entry) => String(entry.metadata?.source ?? "") === "runtime_signal_progress") - ).toHaveLength(0); - } - } finally { - fixture.dispose(); - } - }); - - it("creates a worker thread as soon as an attempt is attached, before any progress messages arrive", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Create worker chat threads immediately when attempts start.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - ["session-worker-bootstrap", attempt.id] - ); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - reason: "session_attached", - at: new Date().toISOString() - }); - - await waitFor(() => { - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - return Boolean(thread?.threadType === "worker" && thread.sessionId === "session-worker-bootstrap"); - }); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - - expect(thread?.title).toBe("Worker: implement-changes"); - expect(thread?.status).toBe("active"); - await waitFor(() => { - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread?.id ?? "" - }); - return messages.some((entry) => String(entry.metadata?.source ?? "") === "worker_lifecycle_started"); - }); - expect( - fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread?.id ?? "" - }).some((entry) => - entry.role === "worker" - && String(entry.metadata?.source ?? "") === "worker_lifecycle_started" - && entry.content.includes("starting this task now"), - ) - ).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("creates a placeholder worker thread on started events even when the run graph has not surfaced the attempt yet", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Create worker chat threads immediately when attempts start.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const originalGetRunGraph = fixture.orchestratorService.getRunGraph.bind(fixture.orchestratorService); - const getRunGraphSpy = vi.spyOn(fixture.orchestratorService, "getRunGraph").mockImplementation((args) => { - const currentGraph = originalGetRunGraph(args); - return { - ...currentGraph, - attempts: [], - }; - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - reason: "started", - at: new Date().toISOString() - }); - - await waitFor(() => { - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - return Boolean(thread?.threadType === "worker"); - }); - - getRunGraphSpy.mockRestore(); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - - expect(thread?.title).toBe("Worker: implement-changes"); - expect(thread?.status).toBe("active"); - expect(thread?.sessionId ?? null).toBeNull(); - } finally { - vi.restoreAllMocks(); - fixture.dispose(); - } - }); - - it("hydrates running worker threads when listing conversations after a missed start event", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "List running worker threads from persisted attempts.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "worker-live", title: "Live worker", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.stepKey === "worker-live"); - if (!readyStep) throw new Error("Expected worker-live step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_session_id = ? - where id = ? - `, - ["session-live-worker", attempt.id] - ); - fixture.db.run( - ` - delete from orchestrator_chat_threads - where mission_id = ? - and thread_type = 'worker' - `, - [mission.id] - ); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - - expect(thread?.threadType).toBe("worker"); - expect(thread?.title).toBe("Worker: worker-live"); - expect(thread?.status).toBe("active"); - expect(thread?.sessionId).toBe("session-live-worker"); - } finally { - fixture.dispose(); - } - }); - - it("does not hydrate stale running attempts on terminal steps", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Ignore stale running attempts on skipped steps.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "worker-stale", title: "Stale worker", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.stepKey === "worker-stale"); - if (!readyStep) throw new Error("Expected worker-stale step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_session_id = ? - where id = ? - `, - ["session-stale-worker", attempt.id] - ); - fixture.db.run( - ` - update orchestrator_steps - set status = 'skipped' - where id = ? - `, - [readyStep.id] - ); - fixture.db.run( - ` - delete from orchestrator_chat_threads - where mission_id = ? - and thread_type = 'worker' - `, - [mission.id] - ); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - - expect(thread).toBeUndefined(); - } finally { - fixture.dispose(); - } - }); - - it("reconciles attempts immediately on terminal runtime end signals", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Detect ended sessions and reconcile without waiting for periodic sweeps.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-runtime-ended-1"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - fixture.db.run( - ` - insert 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 - ) values (?, ?, null, 1, 'Worker', ?, ?, 0, ?, null, null, 'completed', null, null, 'codex-orchestrated', null) - `, - [ - sessionId, - fixture.laneId, - "2026-02-20T00:00:00.000Z", - "2026-02-20T00:05:00.000Z", - path.join(fixture.projectRoot, ".ade", "transcripts", "session-runtime-ended-1.log") - ] - ); - fs.mkdirSync(path.join(fixture.projectRoot, ".ade", "transcripts"), { recursive: true }); - fs.writeFileSync( - path.join(fixture.projectRoot, ".ade", "transcripts", "session-runtime-ended-1.log"), - "Worker finished the task and confirmed completion before exit.\n", - "utf8" - ); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "exited", - lastOutputPreview: "Done", - at: new Date().toISOString() - }); - - await waitFor(() => { - const refreshed = fixture.orchestratorService.getRunGraph({ runId }); - const matched = refreshed.attempts.find((entry) => entry.id === attempt.id); - return matched?.status === "succeeded"; - }); - } finally { - fixture.dispose(); - } - }); - - it("replays open questions after restart from runtime event bus deterministically", async () => { - const fixture = await createFixture(); - let restartedService: ReturnType | null = null; - try { - const mission = fixture.missionService.create({ - prompt: "Keep waiting-input awareness across orchestrator restarts.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-runtime-restart-1"; - const transcriptPath = path.join(fixture.projectRoot, ".ade", "transcripts", `${sessionId}.log`); - fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); - fs.writeFileSync(transcriptPath, "still waiting...\n", "utf8"); - - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - fixture.db.run( - ` - insert 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, 'codex-orchestrated', null, ?) - `, - [ - sessionId, - fixture.laneId, - "2026-02-20T00:00:00.000Z", - transcriptPath, - "still waiting...", - new Date().toISOString() - ] - ); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "waiting-input", - lastOutputPreview: "still waiting...", - at: new Date().toISOString() - }); - - await waitFor(() => { - const row = fixture.db.get<{ runtime_state: string | null }>( - `select runtime_state from orchestrator_attempt_runtime where attempt_id = ? limit 1`, - [attempt.id] - ); - return String(row?.runtime_state ?? "") === "waiting-input"; - }); - - fixture.db.run( - `delete from mission_interventions where mission_id = ? and intervention_type = 'manual_input'`, - [mission.id] - ); - fixture.db.run(`delete from orchestrator_attempt_runtime where attempt_id = ?`, [attempt.id]); - - fixture.aiOrchestratorService.dispose(); - restartedService = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - projectRoot: fixture.projectRoot - }); - - await restartedService.runHealthSweep("restart_hydrate"); - - const refreshed = fixture.missionService.get(mission.id); - expect( - refreshed?.interventions.some( - (entry) => - entry.status === "open" && - entry.interventionType === "manual_input" && - String(entry.metadata?.attemptId ?? "") === attempt.id - ) - ).toBe(true); - } finally { - restartedService?.dispose(); - fixture.dispose(); - } - }); - - it("resumeActiveTeamRuntimes pages through all active runs instead of stopping at 10", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Recover all active team runtimes after restart.", - laneId: fixture.laneId - }); - - const runIds: string[] = []; - for (let index = 0; index < 12; index += 1) { - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - metadata: { seed: index } - }); - runIds.push(started.run.id); - - const ts = `2026-03-03T00:${String(index).padStart(2, "0")}:00.000Z`; - fixture.db.run( - `update orchestrator_runs set status = 'active', created_at = ?, updated_at = ? where id = ?`, - [ts, ts, started.run.id] - ); - fixture.db.run( - ` - insert into orchestrator_run_state( - run_id, - phase, - completion_requested, - completion_validated, - last_validation_error, - coordinator_session_id, - teammate_ids_json, - created_at, - updated_at - ) values (?, 'executing', 0, 0, null, null, '[]', ?, ?) - on conflict(run_id) do update set - phase = excluded.phase, - updated_at = excluded.updated_at - `, - [started.run.id, ts, ts] - ); - } - - const resumeRunSpy = vi.spyOn(fixture.orchestratorService, "resumeRun"); - fixture.aiOrchestratorService.resumeActiveTeamRuntimes(); - await waitFor(() => resumeRunSpy.mock.calls.length >= runIds.length, 6_000); - - expect(resumeRunSpy.mock.calls.length).toBe(runIds.length); - } finally { - fixture.dispose(); - } - }); - - it("clears persisted runtime rows once attempts become terminal", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Persist runtime tracking while running and clean it on terminal status.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - - const sessionId = "session-runtime-cleanup-1"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "running", - lastOutputPreview: "processing work", - at: new Date().toISOString() - }); - - await waitFor(() => { - const row = fixture.db.get<{ attempt_id: string | null }>( - `select attempt_id from orchestrator_attempt_runtime where attempt_id = ? limit 1`, - [attempt.id] - ); - return String(row?.attempt_id ?? "") === attempt.id; - }); - - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "deterministic", - errorMessage: "cleanup check" - }); - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "failed" - }); - - const persisted = fixture.db.get<{ attempt_id: string | null }>( - `select attempt_id from orchestrator_attempt_runtime where attempt_id = ? limit 1`, - [attempt.id] - ); - expect(persisted).toBeNull(); - } finally { - fixture.dispose(); - } - }); - - it("emits an offline coordinator notice when no active run is available", async () => { - const mockAi = createMockAiIntegrationService({ - executeTask: vi.fn().mockResolvedValue({ - text: "Acknowledged.", - structuredOutput: null, - provider: "claude", - model: "sonnet", - sessionId: null, - inputTokens: 80, - outputTokens: 20, - durationMs: 300 - }) - }); - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const mission = fixture.missionService.create({ - prompt: "Keep prompts concise while preserving decision-relevant context.", - laneId: fixture.laneId - }); - - const hugeMessage = `Need a compact response context.\n${"x".repeat(12_000)}`; - fixture.aiOrchestratorService.sendChat({ - missionId: mission.id, - content: hugeMessage - }); - - await waitFor(() => - fixture.aiOrchestratorService.getChat({ missionId: mission.id }) - .some((entry) => entry.role === "orchestrator" && entry.content.includes("runtime is not currently online")) - ); - expect((mockAi.executeTask as any).mock.calls.length).toBe(0); - } finally { - fixture.dispose(); - } - }); - - it("evaluateWorkerPlan approves when AI returns approved=true", async () => { - const mockAi = createMockAiIntegrationService({ - executeTask: vi.fn().mockResolvedValue({ - text: "", - structuredOutput: { - approved: true, - feedback: "Output meets quality criteria.", - suggestedAction: "accept" - }, - provider: "claude", - model: "sonnet", - sessionId: null, - inputTokens: 100, - outputTokens: 50, - durationMs: 1000 - }) - }); - - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const result = await fixture.aiOrchestratorService.evaluateWorkerPlan({ - attemptId: "a-1", - workerPlan: { action: "edit files", filesModified: 3 }, - provider: "claude" - }); - expect(result.approved).toBe(true); - expect(result.feedback).toBe("Output meets quality criteria."); - } finally { - fixture.dispose(); - } - }); - - it("evaluateWorkerPlan rejects when AI returns approved=false", async () => { - const mockAi = createMockAiIntegrationService({ - executeTask: vi.fn().mockResolvedValue({ - text: "", - structuredOutput: { - approved: false, - feedback: "Output has scope violations.", - scopeViolations: ["Modified files outside reservation"], - suggestedAction: "retry_with_feedback" - }, - provider: "claude", - model: "sonnet", - sessionId: null, - inputTokens: 100, - outputTokens: 50, - durationMs: 1000 - }) - }); - - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const result = await fixture.aiOrchestratorService.evaluateWorkerPlan({ - attemptId: "a-2", - workerPlan: { action: "edit outside scope" }, - provider: "claude" - }); - expect(result.approved).toBe(false); - expect(result.feedback).toBe("Output has scope violations."); - } finally { - fixture.dispose(); - } - }); - - it("evaluateWorkerPlan flags for manual review when AI is unavailable", async () => { - const fixture = await createFixture({ aiIntegrationService: null }); - try { - const result = await fixture.aiOrchestratorService.evaluateWorkerPlan({ - attemptId: "a-1", - workerPlan: { action: "edit files" }, - provider: "claude" - }); - expect(result.approved).toBe(false); - expect(result.feedback).toContain("unavailable"); - } finally { - fixture.dispose(); - } - }); - - it("evaluateWorkerPlan flags for manual review when AI throws", async () => { - const mockAi = createMockAiIntegrationService({ - executeTask: vi.fn().mockRejectedValue(new Error("AI service timeout")) - }); - - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const result = await fixture.aiOrchestratorService.evaluateWorkerPlan({ - attemptId: "a-err", - workerPlan: { action: "edit files" }, - provider: "claude" - }); - expect(result.approved).toBe(false); - expect(result.feedback).toContain("failed"); - } finally { - fixture.dispose(); - } - }); - - it("handleInterventionWithAI auto-resolves with high confidence", async () => { - const mockAi = createMockAiIntegrationService({ - executeTask: vi.fn().mockResolvedValue({ - text: "", - structuredOutput: { - autoResolvable: true, - confidence: 0.9, - suggestedAction: "retry", - reasoning: "The failure was transient. Retrying should succeed." - }, - provider: "claude", - model: "sonnet", - sessionId: null, - inputTokens: 100, - outputTokens: 50, - durationMs: 1000 - }) - }); - - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const mission = fixture.missionService.create({ - prompt: "Test intervention resolution.", - laneId: fixture.laneId - }); - // Transition to in_progress before adding intervention - fixture.missionService.update({ missionId: mission.id, status: "planning" }); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const intervention = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: "Step failed after retries", - body: "Step 1 failed 3 times.", - requestedAction: "Review and decide next action." - }); - - const result = await fixture.aiOrchestratorService.handleInterventionWithAI({ - missionId: mission.id, - interventionId: intervention.id, - provider: "claude" - }); - - expect(result.autoResolved).toBe(true); - expect(result.suggestion).toContain("transient"); - } finally { - fixture.dispose(); - } - }); - - it("handleInterventionWithAI escalates with low confidence", async () => { - const mockAi = createMockAiIntegrationService({ - executeTask: vi.fn().mockResolvedValue({ - text: "", - structuredOutput: { - autoResolvable: false, - confidence: 0.3, - suggestedAction: "escalate", - reasoning: "The error indicates a configuration issue that requires human review." - }, - provider: "claude", - model: "sonnet", - sessionId: null, - inputTokens: 100, - outputTokens: 50, - durationMs: 1000 - }) - }); - - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const mission = fixture.missionService.create({ - prompt: "Test intervention escalation.", - laneId: fixture.laneId - }); - - const result = await fixture.aiOrchestratorService.handleInterventionWithAI({ - missionId: mission.id, - interventionId: "i-1", - provider: "codex" - }); - - expect(result.autoResolved).toBe(false); - expect(result.suggestion).toContain("configuration issue"); - } finally { - fixture.dispose(); - } - }); - - it("handleInterventionWithAI gracefully degrades when AI is unavailable", async () => { - const fixture = await createFixture({ aiIntegrationService: null }); - try { - const result = await fixture.aiOrchestratorService.handleInterventionWithAI({ - missionId: "m-1", - interventionId: "i-1", - provider: "codex" - }); - expect(result.autoResolved).toBe(false); - expect(result.suggestion).toBeNull(); - } finally { - fixture.dispose(); - } - }); - - it("handleInterventionWithAI never auto-resolves planner contract failures", async () => { - const executeTask = vi.fn(); - const mockAi = createMockAiIntegrationService({ - executeTask, - }); - - const fixture = await createFixture({ aiIntegrationService: mockAi }); - try { - const mission = fixture.missionService.create({ - prompt: "Planner contract failure.", - laneId: fixture.laneId - }); - fixture.missionService.update({ missionId: mission.id, status: "planning" }); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const intervention = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: "Planner result missing plan", - body: "Planner output did not include report_result.plan.markdown.", - requestedAction: "Retry planning after fixing the planner output.", - metadata: { - reasonCode: "planner_plan_missing", - }, - }); - - const result = await fixture.aiOrchestratorService.handleInterventionWithAI({ - missionId: mission.id, - interventionId: intervention.id, - provider: "claude" - }); - - expect(result.autoResolved).toBe(false); - expect(result.suggestion).toContain("report_result.plan.markdown"); - expect(executeTask).not.toHaveBeenCalled(); - } finally { - fixture.dispose(); - } - }); - - it("startMissionRun goes to in_progress even without AI integration in AI-first flow", async () => { - const fixture = await createFixture({ aiIntegrationService: null }); - try { - const mission = fixture.missionService.create({ - prompt: "AI-planned mission.", - laneId: fixture.laneId - }); - - // In the AI-first flow, startMissionRun always starts the run and goes to in_progress. - // The coordinator handles AI availability internally. - const result = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - plannerProvider: "claude" - }); - - expect(result.started).toBeTruthy(); - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("keeps mandatory Planning as the initial runtime phase when legacy policy disables planning", async () => { - const fixture = await createFixture({ aiIntegrationService: null }); - try { - const mission = fixture.missionService.create({ - prompt: "Build a launch dashboard with a required planning pass.", - laneId: fixture.laneId - }); - setMissionPlanningMode(fixture.db, mission.id, "off"); - - const result = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - plannerProvider: "claude" - }); - - expect(result.started).toBeTruthy(); - const metadata = result.started?.run.metadata ?? {}; - const phaseRuntime = metadata.phaseRuntime as Record | undefined; - const phaseOverride = Array.isArray(metadata.phaseOverride) ? metadata.phaseOverride : []; - expect(phaseRuntime?.currentPhaseKey).toBe("planning"); - expect(phaseRuntime?.currentPhaseName).toBe("Planning"); - expect((phaseOverride[0] as { phaseKey?: string } | undefined)?.phaseKey).toBe("planning"); - } finally { - fixture.dispose(); - } - }); - - it("startMissionRun moves a canceled mission back to in_progress for an explicit rerun", async () => { - const fixture = await createFixture({ aiIntegrationService: null }); - try { - const mission = fixture.missionService.create({ - prompt: "Rerun a canceled mission.", - laneId: fixture.laneId - }); - fixture.missionService.update({ missionId: mission.id, status: "canceled" }); - - const result = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - plannerProvider: "claude" - }); - - expect(result.started).toBeTruthy(); - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("allocates distinct mission lanes when starting multiple runs for the same mission", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Run mission with restart-safe lane reuse.", - laneId: fixture.laneId, - }); - - const firstStart = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - }); - expect(firstStart.started).toBeTruthy(); - const firstRunId = firstStart.started!.run.id; - - expect(readRunMissionLaneId(fixture.db, firstRunId)).toBe("mission-lane-1"); - - const secondStart = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - }); - - const secondRunId = secondStart.started?.run.id; - expect(secondRunId).toBeTruthy(); - expect(secondRunId).not.toBe(firstRunId); - expect(fixture.laneService.createChild).toHaveBeenCalledTimes(2); - if (!secondRunId) { - throw new Error("Expected second run id"); - } - - expect(readRunMissionLaneId(fixture.db, secondRunId)).toBe("mission-lane-2"); - } finally { - fixture.dispose(); - } - }); - - it("fallback lane strategy keeps independent roots off a single forced base lane", () => { - const decision = deriveFallbackLaneStrategyDecision({ - baseLaneId: "lane-base", - descriptors: [ - { - id: "step-1", - index: 0, - title: "Root A", - kind: "implementation", - laneId: "lane-base", - stepType: "implementation", - stepKey: "root-a", - dependencyStepKeys: [], - }, - { - id: "step-2", - index: 1, - title: "Root B", - kind: "implementation", - laneId: null, - stepType: "implementation", - stepKey: "root-b", - dependencyStepKeys: [], - }, - { - id: "step-3", - index: 2, - title: "Integrate", - kind: "integration", - laneId: null, - stepType: "integration", - stepKey: "join", - dependencyStepKeys: ["root-a", "root-b"], - }, - ] as any, - }); - - const assignments = new Map(decision.stepAssignments.map((entry) => [entry.stepKey, entry.laneLabel])); - expect(decision.strategy).toBe("dependency_parallel"); - expect(assignments.get("root-a")).toBe("base"); - expect(assignments.get("root-b")).toMatch(/^parallel-/); - expect(assignments.get("join")).toBe("base"); - }); - - it("auto-creates lanes for independent parallel steps and assigns downstream lanes", async () => { - // laneService.createChild must insert the lane row into the DB so that - // foreign key constraints on mission_steps.lane_id -> lanes.id are satisfied. - let fixtureRef: Awaited> | null = null; - let laneCounter = 0; - const laneService = { - createChild: vi.fn().mockImplementation(async () => { - const f = fixtureRef!; - laneCounter += 1; - const laneId = `lane-child-${laneCounter}`; - const childNow = new Date().toISOString(); - f.db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, f.projectId, `m-auto-child-${laneCounter}`, null, "worktree", - "main", `feature/${laneId}`, f.projectRoot, null, 0, f.laneId, - null, null, null, "active", childNow, null - ] - ); - return { id: laneId, name: `m-auto-child-${laneCounter}` }; - }) - }; - const fixture = await createFixture({ laneService }); - fixtureRef = fixture; - try { - const mission = fixture.missionService.create({ - prompt: "Implement two independent units in parallel, then integrate.", - laneId: fixture.laneId - }); - - const now = "2026-02-21T00:00:00.000Z"; - fixture.db.run(`delete from mission_steps where mission_id = ?`, [mission.id]); - fixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-par-1', ?, ?, 0, 'Build API slice', 'Implement API changes', 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-par-2', ?, ?, 1, 'Build UI slice', 'Implement UI changes', 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-par-3', ?, ?, 2, 'Integrate and verify', 'Join outputs and verify', 'integration', ?, 'pending', '{"stepType":"integration","dependencyIndices":[0,1],"joinPolicy":"all_success"}', ?, ?, null, null) - `, - [ - mission.id, - fixture.projectId, - fixture.laneId, - now, - now, - mission.id, - fixture.projectId, - fixture.laneId, - now, - now, - mission.id, - fixture.projectId, - fixture.laneId, - now, - now - ] - ); - - setMissionPlanningMode(fixture.db, mission.id, "off"); - const started = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "manual", - }); - // In the AI-first flow, the run starts with empty steps and the coordinator manages lanes - expect(started.started).toBeTruthy(); - - const runs = fixture.orchestratorService.listRuns({ missionId: mission.id }); - expect(runs.length).toBeGreaterThan(0); - // The run starts with empty user steps — the coordinator may create - // system-managed tracking steps (e.g. planner-launch-tracker), so filter those out. - const graph = fixture.orchestratorService.getRunGraph({ runId: runs[0]!.id }); - expect(filterExecutionSteps(graph.steps)).toHaveLength(0); - - const refreshedMission = fixture.missionService.get(mission.id); - expect(refreshedMission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("does not create duplicate child lanes when parallel roots are already assigned", async () => { - let fixtureRef: Awaited> | null = null; - let laneCounter = 0; - const laneService = { - createChild: vi.fn().mockImplementation(async () => { - const f = fixtureRef!; - laneCounter += 1; - const laneId = `lane-child-unexpected-${laneCounter}`; - const childNow = new Date().toISOString(); - f.db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, - f.projectId, - `unexpected-${laneCounter}`, - null, - "worktree", - "main", - `feature/${laneId}`, - f.projectRoot, - null, - 0, - f.laneId, - null, - null, - null, - "active", - childNow, - null, - ] - ); - return { id: laneId, name: `unexpected-${laneCounter}` }; - }) - }; - const fixture = await createFixture({ laneService }); - fixtureRef = fixture; - try { - const mission = fixture.missionService.create({ - prompt: "Run existing parallel workstreams and integrate.", - laneId: fixture.laneId - }); - - const now = "2026-02-21T00:00:00.000Z"; - fixture.db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - "lane-child-existing", - fixture.projectId, - "Child Existing", - null, - "worktree", - "main", - "feature/lane-child-existing", - fixture.projectRoot, - null, - 0, - fixture.laneId, - null, - null, - null, - "active", - now, - null - ] - ); - - fixture.db.run(`delete from mission_steps where mission_id = ?`, [mission.id]); - fixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-pre-1', ?, ?, 0, 'Build API slice', 'Implement API changes', 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-pre-2', ?, ?, 1, 'Build UI slice', 'Implement UI changes', 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-pre-3', ?, ?, 2, 'Integrate and verify', 'Join outputs and verify', 'integration', ?, 'pending', '{"stepType":"integration","dependencyIndices":[0,1],"joinPolicy":"all_success"}', ?, ?, null, null) - `, - [ - mission.id, - fixture.projectId, - fixture.laneId, - now, - now, - mission.id, - fixture.projectId, - "lane-child-existing", - now, - now, - mission.id, - fixture.projectId, - fixture.laneId, - now, - now - ] - ); - - setMissionPlanningMode(fixture.db, mission.id, "off"); - const started = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "manual", - }); - // In the AI-first flow, the run starts with empty steps and goes to in_progress - expect(started.started).toBeTruthy(); - - const refreshedMission = fixture.missionService.get(mission.id); - expect(refreshedMission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("keeps mission startup active when lane assignment gaps are left for the coordinator to resolve", async () => { - const executeTask = vi.fn().mockImplementation(async (request: { prompt?: string }) => { - const prompt = String(request?.prompt ?? ""); - if (prompt.includes("Decision: lane strategy")) { - const laneDecision = { - strategy: "dependency_parallel", - maxParallelLanes: 2, - rationale: "Attempt parallel split.", - confidence: 0.9, - stepAssignments: [ - { stepKey: "step-a", laneLabel: "base", rationale: "Primary stream." } - ] - }; - return createAiTaskResult(laneDecision); - } - return createAiTaskResult(buildDefaultDecisionStructuredOutput(prompt)); - }); - let fixtureRef: Awaited> | null = null; - let laneCounter = 0; - const laneService = { - createChild: vi.fn().mockImplementation(async () => { - const f = fixtureRef!; - laneCounter += 1; - const laneId = `lane-unused-${laneCounter}`; - const childNow = new Date().toISOString(); - f.db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, - f.projectId, - laneId, - null, - "worktree", - "main", - `feature/${laneId}`, - f.projectRoot, - null, - 0, - f.laneId, - null, - null, - null, - "active", - childNow, - null, - ] - ); - return { id: laneId, name: laneId }; - }) - }; - const fixture = await createFixture({ - aiIntegrationService: createMockAiIntegrationService({ executeTask }), - laneService - }); - fixtureRef = fixture; - try { - const mission = fixture.missionService.create({ - prompt: "Run in parallel and then integrate.", - laneId: fixture.laneId, - plannedSteps: [ - { index: 0, title: "Step A", detail: "A", kind: "implementation", metadata: { stepType: "implementation", stepKey: "step-a", dependencyStepKeys: [] } }, - { index: 1, title: "Step B", detail: "B", kind: "implementation", metadata: { stepType: "implementation", stepKey: "step-b", dependencyStepKeys: [] } }, - { index: 2, title: "Integrate", detail: "Join", kind: "integration", metadata: { stepType: "integration", stepKey: "step-c", dependencyStepKeys: ["step-a", "step-b"] } } - ] - }); - - setMissionPlanningMode(fixture.db, mission.id, "off"); - const started = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "manual", - }); - // Startup no longer performs synchronous AI lane-assignment validation. - // The coordinator owns task creation and any later lane-assignment recovery. - expect(started.started).toBeTruthy(); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("gracefully degrades when AI parallelism decision fails by using default cap", async () => { - const executeTask = vi.fn().mockImplementation(async (request: { prompt?: string }) => { - const prompt = String(request?.prompt ?? ""); - if (prompt.toLowerCase().includes("decision: parallelism cap")) { - return createAiTaskResult(null, "not-json"); - } - return createAiTaskResult(buildDefaultDecisionStructuredOutput(prompt)); - }); - const fixture = await createFixture({ - aiIntegrationService: createMockAiIntegrationService({ executeTask }) - }); - try { - const mission = fixture.missionService.create({ - prompt: "Run a simple mission with deterministic guardrails only.", - laneId: fixture.laneId, - plannedSteps: [ - { - index: 0, - title: "Single step", - detail: "Implement a scoped change", - kind: "implementation", - metadata: { stepType: "implementation" } - } - ] - }); - setMissionPlanningMode(fixture.db, mission.id, "off"); - - const started = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - - expect(started.started).toBeTruthy(); - - // Parallelism decision failure is now a soft failure — the run proceeds - // with a default parallelism cap instead of pausing for intervention. - const refreshedMission = fixture.missionService.get(mission.id); - expect(refreshedMission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("handles completed shadow events without invoking legacy deterministic plan adjustment", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Build feature and test.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.stepKey === "implement-changes"); - if (!readyStep) throw new Error("Expected implement-changes step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded" - }); - - // Completed shadow event should be tolerated and should not throw. - expect(() => { - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "completed" - }); - }).not.toThrow(); - - const states = fixture.aiOrchestratorService.getWorkerStates({ runId }); - const targetState = states.find((ws) => ws.attemptId === attempt.id); - expect(targetState).toBeTruthy(); - expect(targetState!.state).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("uses the mission coordinator model for AI plan adjustment", async () => { - const executeTask = vi.fn().mockImplementation(async (request: { prompt?: string }) => { - const prompt = String(request?.prompt ?? ""); - if (prompt.includes("PLAN ADJUSTER")) { - return createAiTaskResult({ - reasoning: "No adjustment needed.", - adjustments: [{ action: "no_change", reason: "The remaining plan is still valid." }] - }); - } - return createAiTaskResult(buildDefaultDecisionStructuredOutput(prompt)); - }); - const fixture = await createFixture({ - aiIntegrationService: createMockAiIntegrationService({ executeTask }) - }); - try { - const coordinatorModel = { - provider: "codex" as const, - modelId: "openai/gpt-5.5", - thinkingLevel: "low" as const - }; - const mission = fixture.missionService.create({ - prompt: "Verify model routing for plan adjustment.", - laneId: fixture.laneId, - modelConfig: { - orchestratorModel: coordinatorModel, - intelligenceConfig: { - coordinator: coordinatorModel, - chat_response: coordinatorModel - } - }, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "alpha", - title: "Alpha", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation" }, - }, - { - stepKey: "beta", - title: "Beta", - stepIndex: 1, - dependencyStepKeys: ["alpha"], - executorKind: "manual", - metadata: { stepType: "test" }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - fixture.orchestratorService.tick({ runId: started.run.id }); - - const graphBefore = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const alpha = graphBefore.steps.find((entry) => entry.stepKey === "alpha"); - if (!alpha) throw new Error("Expected alpha step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: started.run.id, - stepId: alpha.id, - ownerId: "alpha-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Alpha finished cleanly.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId: started.run.id, - stepId: alpha.id, - attemptId: attempt.id, - at: new Date().toISOString(), - reason: "attempt_completed", - }); - - await waitFor(() => executeTask.mock.calls.some(([request]) => - String(request?.prompt ?? "").includes("PLAN ADJUSTER") - )); - const [planAdjustRequest] = executeTask.mock.calls.find(([request]) => - String(request?.prompt ?? "").includes("PLAN ADJUSTER") - ) ?? []; - expect(planAdjustRequest).toMatchObject({ - provider: "codex", - model: "openai/gpt-5.5", - reasoningEffort: "low", - }); - } finally { - fixture.dispose(); - } - }); - - it("synchronizes mission step and mission status from orchestrator runtime state", async () => { - const fixture = await createFixture(); - try { - const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default phase profile"); - const mission = fixture.missionService.create({ - prompt: "Implement endpoint update, test, and summarize outcome.", - laneId: fixture.laneId, - phaseProfileId: defaultProfile.id, - phaseOverride: defaultProfile.phases.map((phase, index) => ({ - ...phase, - position: index, - validationGate: { - ...phase.validationGate, - tier: "none", - required: false, - criteria: undefined, - evidenceRequirements: undefined, - }, - })), - plannedSteps: [ - { - index: 0, - title: "Implement endpoint update", - detail: "Write the change", - kind: "implementation", - metadata: { stepType: "implementation" } - }, - { - index: 1, - title: "Test endpoint update", - detail: "Verify behavior", - kind: "test", - metadata: { stepType: "test" } - }, - ], - }); - - // Get mission steps created by the deterministic planner and map them to orchestrator steps - const missionDetail = fixture.missionService.get(mission.id); - const missionSteps = missionDetail?.steps ?? []; - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: missionSteps.map((ms, idx) => ({ - stepKey: `step-${idx}`, - title: ms.title, - stepIndex: idx, - dependencyStepKeys: [], - executorKind: "manual" as const, - missionStepId: ms.id, - metadata: { - instructions: "Do the work", - stepType: ms.kind === "test" ? "test" : "implementation", - } - })) - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - const runId = started.run.id; - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - - const completionAt = new Date().toISOString(); - fixture.db.run( - `update orchestrator_steps set status = 'succeeded', completed_at = ?, updated_at = ? where run_id = ?`, - [completionAt, completionAt, runId], - ); - markRunStepValidationPassed(fixture.db, runId); - - const finalizeResult = fixture.aiOrchestratorService.finalizeRun({ runId }); - expect(finalizeResult.finalized).toBe(true); - 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); - expect((refreshed?.outcomeSummary ?? "").length).toBeGreaterThan(0); - } finally { - fixture.dispose(); - } - }); - - it("keeps intervention_required when step sync opens a failed-step intervention", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Keep mission status aligned with interventions created during sync.", - laneId: fixture.laneId, - plannedSteps: [ - { - index: 0, - title: "Implement risky change", - detail: "Make the update", - kind: "implementation", - metadata: { stepType: "implementation" } - } - ] - }); - const missionStep = fixture.missionService.get(mission.id)?.steps[0]; - if (!missionStep) throw new Error("Expected mission step"); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "implement-risky-change", - title: missionStep.title, - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - missionStepId: missionStep.id, - metadata: { stepType: "implementation" } - } - ] - }); - const runId = started.run.id; - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - const failedAt = new Date().toISOString(); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [failedAt, runId], - ); - fixture.db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, - [failedAt, failedAt, runId], - ); - - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "step_failed"); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("intervention_required"); - expect(refreshed?.steps[0]?.status).toBe("failed"); - expect(refreshed?.interventions.some((entry) => - entry.status === "open" && entry.interventionType === "failed_step" - )).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("keeps intervention_required when a failed run opens a failed-step intervention", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Keep terminal failed runs recoverable when sync opens an intervention.", - laneId: fixture.laneId, - plannedSteps: [ - { - index: 0, - title: "Implement risky change", - detail: "Make the update", - kind: "implementation", - metadata: { stepType: "implementation" } - } - ] - }); - const missionStep = fixture.missionService.get(mission.id)?.steps[0]; - expect(missionStep?.id).toBeTruthy(); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "implement-risky-change", - title: missionStep!.title, - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - missionStepId: missionStep!.id, - metadata: { stepType: "implementation" } - } - ] - }); - const runId = started.run.id; - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - const failedAt = new Date().toISOString(); - fixture.db.run( - `update orchestrator_runs set status = 'failed', updated_at = ? where id = ?`, - [failedAt, runId], - ); - fixture.db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, - [failedAt, failedAt, runId], - ); - - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "run_failed"); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("intervention_required"); - expect(refreshed?.interventions.some((entry) => - entry.status === "open" && entry.interventionType === "failed_step" - )).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("opens terminal failed-step interventions for each matched failed mission step", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Keep failed-step interventions targeted.", - laneId: fixture.laneId, - plannedSteps: [ - { - index: 0, - title: "Implement API", - detail: "First failure", - kind: "implementation", - metadata: { stepType: "implementation", stepKey: "first-step" } - }, - { - index: 1, - title: "Validate API", - detail: "Second failure", - kind: "test", - metadata: { stepType: "test", stepKey: "second-step" } - } - ] - }); - const missionSteps = fixture.missionService.get(mission.id)?.steps ?? []; - const firstStep = missionSteps[0]; - const secondStep = missionSteps[1]; - if (!firstStep || !secondStep) throw new Error("Expected mission steps"); - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: "Step failed: Implement API", - body: "Existing first-step intervention.", - requestedAction: "Review the failure.", - metadata: { - stepId: firstStep.id, - stepKey: "first-step", - orchestratorStepId: "old-run-step", - reasonCode: "terminal_run_failed_step", - }, - }); - const failedAt = new Date().toISOString(); - fixture.db.run( - `update mission_steps set status = 'failed', completed_at = ?, updated_at = ? where id in (?, ?)`, - [failedAt, failedAt, firstStep.id, secondStep.id], - ); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "first-step", - title: firstStep.title, - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - missionStepId: firstStep.id, - metadata: { stepType: "implementation", stepKey: "first-step" } - }, - { - stepKey: "second-step", - title: secondStep.title, - stepIndex: 1, - dependencyStepKeys: [], - executorKind: "manual", - missionStepId: secondStep.id, - metadata: { stepType: "test", stepKey: "second-step" } - } - ] - }); - const runId = started.run.id; - fixture.db.run( - `update orchestrator_runs set status = 'failed', updated_at = ? where id = ?`, - [failedAt, runId], - ); - fixture.db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, - [failedAt, failedAt, runId], - ); - - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "run_failed"); - - const refreshed = fixture.missionService.get(mission.id); - const failedStepInterventions = refreshed?.interventions.filter((entry) => - entry.status === "open" && entry.interventionType === "failed_step" - ) ?? []; - expect(failedStepInterventions).toHaveLength(2); - expect(failedStepInterventions.some((entry) => entry.metadata?.stepId === secondStep.id)).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("does not sync run steps to duplicate mission titles without a stable link", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Avoid title-only mission step sync.", - laneId: fixture.laneId, - plannedSteps: [ - { - index: 0, - title: "Implement API", - detail: "First duplicate title", - kind: "implementation", - metadata: { stepType: "implementation" } - }, - { - index: 1, - title: "Implement API", - detail: "Second duplicate title", - kind: "implementation", - metadata: { stepType: "implementation" } - } - ] - }); - const missionSteps = fixture.missionService.get(mission.id)?.steps ?? []; - expect(missionSteps.map((step) => step.title)).toEqual(["Implement API", "Implement API"]); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "unlinked-implement-api", - title: "Implement API", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation" } - } - ] - }); - const runId = started.run.id; - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - const failedAt = new Date().toISOString(); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [failedAt, runId], - ); - fixture.db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, - [failedAt, failedAt, runId], - ); - - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "step_failed"); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.steps.map((step) => step.status)).toEqual(["pending", "pending"]); - expect(refreshed?.status).toBe("in_progress"); - expect(refreshed?.interventions).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("does not sync run steps through blank step keys", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Avoid blank step key mission step sync.", - laneId: fixture.laneId, - plannedSteps: [ - { - index: 0, - title: "Implement API", - detail: "Blank metadata key from legacy state", - kind: "implementation", - metadata: { stepType: "implementation", stepKey: "" } - } - ] - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "runtime-step", - title: "Different runtime step", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation" } - } - ] - }); - const runId = started.run.id; - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - const failedAt = new Date().toISOString(); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [failedAt, runId], - ); - fixture.db.run( - `update orchestrator_steps set step_key = '', status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, - [failedAt, failedAt, runId], - ); - - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "step_failed"); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.steps.map((step) => step.status)).toEqual(["pending"]); - expect(refreshed?.status).toBe("in_progress"); - expect(refreshed?.interventions).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("applies steering directives onto active run steps for worker prompt guidance", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Apply targeted steering to active orchestrator steps.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { stepKey: "alpha", title: "Alpha", stepIndex: 0 }, - { stepKey: "beta", title: "Beta", stepIndex: 1, dependencyStepKeys: ["alpha"] } - ] - }); - - const result = fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - directive: "Prioritize integration tests before non-critical refactors.", - priority: "instruction", - targetStepKey: "beta" - }); - expect(result.acknowledged).toBe(true); - - const graph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const alpha = graph.steps.find((entry) => entry.stepKey === "alpha"); - const beta = graph.steps.find((entry) => entry.stepKey === "beta"); - const betaDirectives = Array.isArray(beta?.metadata?.steeringDirectives) - ? beta?.metadata?.steeringDirectives as Array> - : []; - const alphaDirectives = Array.isArray(alpha?.metadata?.steeringDirectives) - ? alpha?.metadata?.steeringDirectives as Array> - : []; - expect( - betaDirectives.some( - (entry) => - String(entry.directive ?? "").includes("Prioritize integration tests") - && String(entry.priority ?? "") === "instruction" - && String(entry.targetStepKey ?? "") === "beta" - ) - ).toBe(true); - expect(alphaDirectives).toHaveLength(0); - - const steeringEvents = fixture.orchestratorService.listRuntimeEvents({ - runId: started.run.id, - eventTypes: ["coordinator_steering"], - limit: 5, - }); - expect( - steeringEvents.some((entry) => - String((entry.payload as Record | null)?.directive ?? "").includes("Prioritize integration tests") - && (entry.payload as Record | null)?.targetStepKey === "beta" - ), - ).toBe(true); - const timelineGraph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - expect( - timelineGraph.timeline.some((entry) => - entry.eventType === "coordinator_steering" - && String((entry.detail as Record | null)?.directive ?? "").includes("Prioritize integration tests") - ), - ).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("transitions the active run phase when a phase approval intervention is approved", async () => { - const fixture = await createFixture(); - try { - const planningPhase = phaseCard({ - phaseKey: "planning", - name: "Planning", - position: 0, - requiresApproval: true, - orderingConstraints: { mustBeFirst: true }, - }); - const developmentPhase = phaseCard({ - phaseKey: "development", - name: "Development", - position: 1, - orderingConstraints: { mustFollow: ["planning"] }, - }); - const mission = fixture.missionService.create({ - prompt: "Approve planning before development.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - phaseOverride: [planningPhase, developmentPhase], - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - currentPhaseInstructions: planningPhase.instructions, - currentPhaseValidation: planningPhase.validationGate, - currentPhaseBudget: planningPhase.budget, - transitionedAt: "2026-03-01T00:00:00.000Z", - transitions: [ - { - fromPhaseKey: null, - fromPhaseName: null, - toPhaseKey: "planning", - toPhaseName: "Planning", - at: "2026-03-01T00:00:00.000Z", - reason: "run_initialized", - }, - ], - phaseBudgets: { - planning: { - enteredAt: "2026-03-01T00:00:00.000Z", - usedTokens: 0, - usedCostUsd: 0, - }, - }, - }, - }, - steps: [ - { - stepKey: "plan-work", - title: "Plan work", - stepIndex: 0, - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, - ], - }); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const approval = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "phase_approval", - title: "Approve transition from Planning phase", - body: "Approve the planning output.", - requestedAction: "Approve the Planning output to proceed to Development.", - pauseMission: true, - metadata: { - runId: started.run.id, - phaseKey: "planning", - phaseName: "Planning", - targetPhaseKey: "development", - targetPhaseName: "Development", - source: "phase_approval_gate", - }, - }); - - fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - interventionId: approval.id, - directive: "Approved. Continue into development.", - priority: "instruction", - resolutionKind: "answer_provided", - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - const phaseRuntime = graph.run.metadata?.phaseRuntime as Record | undefined; - expect(phaseRuntime?.currentPhaseKey).toBe("development"); - expect(phaseRuntime?.currentPhaseName).toBe("Development"); - expect(phaseRuntime?.currentPhaseModel).toEqual(developmentPhase.model); - expect( - graph.timeline.some( - (entry) => - entry.eventType === "phase_transition" - && entry.reason === "phase_approval_resolved" - && (entry.detail as Record | null)?.interventionId === approval.id, - ), - ).toBe(true); - expect(fixture.missionService.get(mission.id)?.interventions.find((entry) => entry.id === approval.id)?.status).toBe("resolved"); - } finally { - fixture.dispose(); - } - }); - - it("resolves stale phase approval interventions without applying the transition", async () => { - const fixture = await createFixture(); - try { - const planningPhase = phaseCard({ - phaseKey: "planning", - name: "Planning", - position: 0, - requiresApproval: true, - orderingConstraints: { mustBeFirst: true }, - }); - const developmentPhase = phaseCard({ - phaseKey: "development", - name: "Development", - position: 1, - orderingConstraints: { mustFollow: ["planning"] }, - }); - const testingPhase = phaseCard({ - phaseKey: "testing", - name: "Testing", - position: 2, - orderingConstraints: { mustFollow: ["development"] }, - }); - const mission = fixture.missionService.create({ - prompt: "A stale planning approval should close without changing phases.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - phaseOverride: [planningPhase, developmentPhase, testingPhase], - phaseRuntime: { - currentPhaseKey: "testing", - currentPhaseName: "Testing", - currentPhaseModel: testingPhase.model, - currentPhaseInstructions: testingPhase.instructions, - currentPhaseValidation: testingPhase.validationGate, - currentPhaseBudget: testingPhase.budget, - transitionedAt: "2026-03-01T00:05:00.000Z", - transitions: [ - { - fromPhaseKey: "development", - fromPhaseName: "Development", - toPhaseKey: "testing", - toPhaseName: "Testing", - at: "2026-03-01T00:05:00.000Z", - reason: "phase_advanced", - }, - ], - }, - }, - steps: [ - { - stepKey: "test-work", - title: "Test work", - stepIndex: 0, - metadata: { - phaseKey: "testing", - phaseName: "Testing", - stepType: "testing", - }, - }, - ], - }); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const olderApproval = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "phase_approval", - title: "Approve transition from older Planning phase", - body: "Approve the older planning output.", - requestedAction: "Approve the Planning output to proceed to Development.", - pauseMission: true, - metadata: { - runId: started.run.id, - phaseKey: "planning", - phaseName: "Planning", - targetPhaseKey: "development", - targetPhaseName: "Development", - source: "phase_approval_gate", - }, - }); - const approval = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "phase_approval", - title: "Approve transition from Planning phase", - body: "Approve the planning output.", - requestedAction: "Approve the Planning output to proceed to Development.", - pauseMission: true, - metadata: { - runId: started.run.id, - phaseKey: "planning", - phaseName: "Planning", - targetPhaseKey: "development", - targetPhaseName: "Development", - source: "phase_approval_gate", - }, - }); - - fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - interventionId: approval.id, - directive: "Approved, but this approval is now stale.", - priority: "instruction", - resolutionKind: "answer_provided", - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - const phaseRuntime = graph.run.metadata?.phaseRuntime as Record | undefined; - expect(phaseRuntime?.currentPhaseKey).toBe("testing"); - expect( - graph.timeline.some( - (entry) => - entry.eventType === "phase_transition" - && entry.reason === "phase_approval_resolved" - && (entry.detail as Record | null)?.interventionId === approval.id, - ), - ).toBe(false); - - const resolvedApproval = fixture.missionService.get(mission.id)?.interventions.find((entry) => entry.id === approval.id); - expect(resolvedApproval?.status).toBe("resolved"); - expect(resolvedApproval?.resolutionKind).toBe("stale_phase_approval"); - expect(resolvedApproval?.resolutionNote).toContain("no phase transition was applied"); - const resolvedOlderApproval = fixture.missionService.get(mission.id)?.interventions.find((entry) => entry.id === olderApproval.id); - expect(resolvedOlderApproval?.status).toBe("resolved"); - expect(resolvedOlderApproval?.resolutionKind).toBe("stale_phase_approval"); - - const runtimeEvents = fixture.orchestratorService.listRuntimeEvents({ - runId: started.run.id, - eventTypes: ["intervention_resolved", "progress"], - limit: 20, - }); - const resolvedEvent = runtimeEvents.find((entry) => entry.eventType === "intervention_resolved"); - expect((resolvedEvent?.payload as Record | null)?.resolutionKind).toBe("stale_phase_approval"); - expect((resolvedEvent?.payload as Record | null)?.appliedPhaseTransition).toBe(false); - expect( - runtimeEvents.some( - (entry) => - entry.eventType === "progress" - && (entry.payload as Record | null)?.transition === "phase_approval_resolved", - ), - ).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("kicks autopilot after steering so queued recovery workers can start before coordinator re-evaluation", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Steering should start queued recovery work.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1, - }, - }, - steps: [ - { - stepKey: "planning-recovery", - title: "Planning recovery", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "planning", - phaseKey: "planning", - }, - }, - ], - }); - const startReady = vi - .spyOn(fixture.orchestratorService, "startReadyAutopilotAttempts") - .mockResolvedValue(0); - - fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - directive: "Continue recovery planning now.", - priority: "instruction", - }); - - expect(startReady).toHaveBeenCalledWith({ - runId: started.run.id, - reason: "steering_directive", - }); - } finally { - fixture.dispose(); - } - }); - - it("records deterministic question reply linkage and resume transition after steering input", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Require operator reply when worker asks a question, then resume deterministically.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "test-owner", - parallelismCap: 2 - } - }, - steps: [{ stepKey: "question-step", title: "Question Step", stepIndex: 0 }] - }); - - fixture.orchestratorService.tick({ runId: started.run.id }); - const graph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: started.run.id, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - const sessionId = "session-question-thread-1"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id] - ); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId, - runtimeState: "waiting-input", - lastOutputPreview: "Need operator input before continuing.", - at: new Date().toISOString() - }); - - await waitFor(() => { - const refreshed = fixture.missionService.get(mission.id); - return Boolean( - refreshed?.interventions.some( - (entry) => - entry.status === "open" - && entry.interventionType === "manual_input" - && String(entry.metadata?.attemptId ?? "") === attempt.id - ) - ); - }); - const waitingIntervention = fixture.missionService - .get(mission.id) - ?.interventions.find( - (entry) => - entry.status === "open" - && entry.interventionType === "manual_input" - && String(entry.metadata?.attemptId ?? "") === attempt.id - ); - if (!waitingIntervention) throw new Error("Expected waiting intervention"); - - const questionEvent = fixture.orchestratorService - .listRuntimeEvents({ attemptId: attempt.id, eventTypes: ["question"], limit: 5 }) - .find((entry) => entry.eventType === "question"); - expect(questionEvent?.questionLink).toBeTruthy(); - - fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - interventionId: waitingIntervention.id, - directive: "Proceed with option A and keep changes scoped to auth files.", - priority: "instruction", - resolutionKind: "answer_provided", - }); - - await waitFor(() => { - const refreshed = fixture.missionService.get(mission.id); - return Boolean( - refreshed?.interventions.some( - (entry) => - entry.interventionType === "manual_input" - && String(entry.metadata?.attemptId ?? "") === attempt.id - && entry.status === "resolved" - ) - ); - }); - - const runtimeEvents = fixture.orchestratorService.listRuntimeEvents({ - attemptId: attempt.id, - eventTypes: ["intervention_resolved", "progress"], - limit: 20 - }); - const resolvedEvent = runtimeEvents.find((entry) => entry.eventType === "intervention_resolved"); - const resumeEvent = runtimeEvents.find( - (entry) => entry.eventType === "progress" && String((entry.payload as Record | null)?.transition ?? "") === "question_answered_resume" - ); - expect(resolvedEvent?.questionLink?.threadId).toBe(questionEvent?.questionLink?.threadId); - expect(resolvedEvent?.questionLink?.replyTo).toBe(questionEvent?.questionLink?.messageId); - expect(resumeEvent?.questionLink?.replyTo).toBe(questionEvent?.questionLink?.messageId); - - const states = fixture.aiOrchestratorService.getWorkerStates({ runId: started.run.id }); - const worker = states.find((entry) => entry.attemptId === attempt.id); - expect(worker?.state).toBe("working"); - } finally { - fixture.dispose(); - } - }); - - it("requeues sticky-blocked planner natural questions after the answer is provided", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Planner asks a natural-language clarification and should retry after the answer.", - laneId: fixture.laneId - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "planning-worker", - title: "Planning worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - stepType: "planning", - phaseKey: "planning", - readOnlyExecution: true, - }, - } - ] - }); - fixture.orchestratorService.tick({ runId: started.run.id }); - const readyStep = fixture.orchestratorService - .getRunGraph({ runId: started.run.id, timelineLimit: 0 }) - .steps.find((step) => step.stepKey === "planning-worker"); - if (!readyStep) throw new Error("Expected planning step"); - const attempt = await fixture.orchestratorService.startAttempt({ - runId: started.run.id, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - const question = "Should unknown item weights default to `1`?"; - fixture.db.run( - ` - update orchestrator_steps - set status = 'blocked', - metadata_json = ? - where id = ? - `, - [ - JSON.stringify({ - ...(readyStep.metadata ?? {}), - awaitingUserInput: { - source: "planner_natural_question", - question, - }, - blockedSticky: true, - blockedByAttemptId: attempt.id, - blockedErrorClass: "deterministic", - }), - readyStep.id, - ] - ); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "blocked", - errorClass: "deterministic", - errorMessage: `Planning worker is waiting for input: ${question}`, - }); - const blockedStep = fixture.orchestratorService - .getRunGraph({ runId: started.run.id, timelineLimit: 0 }) - .steps.find((step) => step.id === readyStep.id); - fixture.db.run( - ` - update orchestrator_steps - set metadata_json = ? - where id = ? - `, - [ - JSON.stringify({ - ...(blockedStep?.metadata ?? {}), - awaitingUserInput: { - source: "planner_natural_question", - question, - }, - blockedSticky: true, - blockedByAttemptId: attempt.id, - blockedErrorClass: "deterministic", - }), - readyStep.id, - ] - ); - fixture.orchestratorService.pauseRun({ - runId: started.run.id, - reason: `Planning question needs an answer: ${question}`, - metadata: { - pendingPlannerQuestion: { - stepId: readyStep.id, - attemptId: attempt.id, - question, - }, - }, - }); - const intervention = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "manual_input", - title: "Planner question ready", - body: question, - requestedAction: "Answer the planning question.", - laneId: fixture.laneId, - pauseMission: false, - metadata: { - source: "planner_natural_question", - reasonCode: "planner_natural_question", - question, - runId: started.run.id, - stepId: readyStep.id, - stepKey: readyStep.stepKey, - attemptId: attempt.id, - sessionId: "planner-session-1", - phaseKey: "planning", - stepType: "planning", - threadId: `question:${attempt.id}`, - messageId: `question:${attempt.id}:message`, - }, - }); - - fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - interventionId: intervention.id, - directive: "Yes, default missing or invalid weights to 1.", - priority: "instruction", - resolutionKind: "answer_provided", - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - const planningStep = graph.steps.find((step) => step.id === readyStep.id); - expect(graph.run.status).toBe("active"); - expect(graph.run.lastError).toBeNull(); - expect(graph.run.metadata?.pendingPlannerQuestion).toBeUndefined(); - expect(planningStep?.status).toBe("ready"); - expect(planningStep?.metadata?.awaitingUserInput).toBeUndefined(); - expect(planningStep?.metadata?.blockedSticky).toBeUndefined(); - expect(planningStep?.metadata?.lastAnsweredPlannerQuestion).toEqual(expect.objectContaining({ - interventionId: intervention.id, - answer: "Yes, default missing or invalid weights to 1.", - })); - expect(fixture.missionService.get(mission.id)?.interventions.find((entry) => entry.id === intervention.id)?.status).toBe("resolved"); - } finally { - fixture.dispose(); - } - }); - - it("can target a single manual-input intervention when steering from a focused reply modal", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Handle one clarification without clearing unrelated input requests.", - laneId: fixture.laneId - }); - - const first = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "manual_input", - title: "Coordinator clarification", - body: "Question A", - pauseMission: false, - metadata: { - runId: "run-1", - canProceedWithoutAnswer: false, - } - }); - const second = fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "manual_input", - title: "Worker waiting", - body: "Question B", - pauseMission: false, - metadata: { - runId: "run-1", - canProceedWithoutAnswer: false, - } - }); - - fixture.aiOrchestratorService.steerMission({ - missionId: mission.id, - interventionId: first.id, - directive: "Use a normal navigable tab.", - priority: "instruction" - }); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.interventions.find((entry) => entry.id === first.id)?.status).toBe("resolved"); - expect(refreshed?.interventions.find((entry) => entry.id === second.id)?.status).toBe("open"); - } finally { - fixture.dispose(); - } - }); - - it("persists chat and steering directives and hydrates them after service recreation", async () => { - const fixture = await createFixture({ aiIntegrationService: null }); - try { - const mission = fixture.missionService.create({ - prompt: "Keep chat history durable across orchestrator restarts.", - laneId: fixture.laneId - }); - - fixture.aiOrchestratorService.sendChat({ - missionId: mission.id, - content: "Please prioritize tests first." - }); - - const initialMessages = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(initialMessages.some((entry) => entry.role === "user" && entry.content.includes("prioritize tests"))).toBe(true); - expect(initialMessages.some((entry) => entry.role === "orchestrator" && entry.content.includes("Directive received"))).toBe(true); - - const recreatedService = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - projectRoot: fixture.projectRoot - }); - const hydratedMessages = recreatedService.getChat({ missionId: mission.id }); - expect(hydratedMessages.some((entry) => entry.role === "user" && entry.content.includes("prioritize tests"))).toBe(true); - - const metaRow = fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ?`, - [mission.id] - ); - const metadata = metaRow?.metadata_json ? JSON.parse(metaRow.metadata_json) : {}; - expect(Array.isArray(metadata.orchestratorChat)).toBe(true); - expect(Array.isArray(metadata.steeringDirectives)).toBe(true); - expect( - (metadata.steeringDirectives as unknown[]).some( - (entry) => typeof (entry as { directive?: unknown }).directive === "string" - && String((entry as { directive?: unknown }).directive).includes("prioritize tests") - ) - ).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("returns a deterministic telemetry summary for status chat prompts", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Expose orchestrator telemetry on demand.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - expect(launch.started?.run.id).toBeTruthy(); - - fixture.aiOrchestratorService.sendChat({ - missionId: mission.id, - content: "status update please" - }); - - await waitFor(() => - fixture.aiOrchestratorService - .getChat({ missionId: mission.id }) - .some((entry) => entry.role === "orchestrator" && entry.content.includes("Progress")) - ); - - const messages = fixture.aiOrchestratorService.getChat({ missionId: mission.id }); - expect(messages.some((entry) => entry.role === "orchestrator" && entry.content.includes("Progress"))).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("does not invoke one-shot chat responses when no active run is available", async () => { - const executeTask = vi - .fn() - .mockResolvedValueOnce({ - text: "Hello. I can help with this mission.", - structuredOutput: null, - provider: "claude", - model: "claude-sonnet-4-6", - sessionId: "chat-session-1", - inputTokens: 42, - outputTokens: 17, - durationMs: 50 - }) - .mockResolvedValueOnce({ - text: "Second response on the same thread.", - structuredOutput: null, - provider: "claude", - model: "claude-sonnet-4-6", - sessionId: "chat-session-1", - inputTokens: 44, - outputTokens: 19, - durationMs: 50 - }); - - const fixture = await createFixture({ - aiIntegrationService: createMockAiIntegrationService({ - executeTask - }) - }); - - try { - const mission = fixture.missionService.create({ - prompt: "Use a persistent orchestrator chat thread.", - laneId: fixture.laneId - }); - - fixture.aiOrchestratorService.sendChat({ - missionId: mission.id, - content: "hello orchestrator" - }); - - await waitFor(() => - fixture.aiOrchestratorService.getChat({ missionId: mission.id }) - .some((entry) => entry.role === "orchestrator" && entry.content.includes("runtime is not currently online")) - ); - - fixture.aiOrchestratorService.sendChat({ - missionId: mission.id, - content: "continue working on this" - }); - - await waitFor(() => - fixture.aiOrchestratorService.getChat({ missionId: mission.id }) - .filter((entry) => entry.role === "orchestrator" && entry.content.includes("runtime is not currently online")) - .length >= 2 - ); - - expect(executeTask).toHaveBeenCalledTimes(0); - } finally { - fixture.dispose(); - } - }); - - it("delivers worker thread guidance through agent chat when the worker session is available", async () => { - const agentChatService = createMockAgentChatService({ - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-1", status: "idle" }]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Deliver operator guidance directly to active worker sessions.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Prioritize the flaky test fix before refactors.", - target: { - kind: "worker", - sessionId: "worker-session-1", - stepKey: "step-fix-tests" - } - }); - - await waitFor(() => { - const updated = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return updated?.deliveryState === "delivered"; - }); - - expect(agentChatService.sendMessage).toHaveBeenCalledTimes(1); - expect(agentChatService.sendMessage).toHaveBeenCalledWith({ - sessionId: "worker-session-1", - text: "Prioritize the flaky test fix before refactors." - }); - expect(agentChatService.steer).not.toHaveBeenCalled(); - } finally { - fixture.dispose(); - } - }); - - it("bridges worker delivery to the single active lane chat session when mapped executor session is non-chat", async () => { - const sendMessage = vi.fn().mockResolvedValue(undefined); - const steer = vi.fn().mockResolvedValue(undefined); - const agentChatService = createMockAgentChatService({ - sendMessage, - steer, - listSessions: vi.fn().mockResolvedValue([ - { - sessionId: "worker-chat-bridge-1", - laneId: "lane-1", - provider: "codex", - model: "gpt-5.3-codex", - status: "idle", - startedAt: new Date().toISOString(), - endedAt: null, - lastActivityAt: new Date().toISOString(), - lastOutputPreview: null, - summary: null - } - ]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Bridge delivery from orchestrated session ids to active worker chat sessions.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Use the active lane chat worker when direct session mapping is unavailable.", - target: { - kind: "worker", - sessionId: "legacy-orchestrated-session-42", - laneId: fixture.laneId, - stepKey: "step-bridge" - } - }); - - await waitFor(() => { - const updated = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return updated?.deliveryState === "delivered"; - }); - - const updated = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - expect(steer).toHaveBeenCalledWith({ - sessionId: "worker-chat-bridge-1", - text: "Use the active lane chat worker when direct session mapping is unavailable." - }); - expect(sendMessage).not.toHaveBeenCalled(); - expect(updated?.metadata).toMatchObject({ - workerDelivery: { - agentSessionId: "worker-chat-bridge-1" - } - }); - } finally { - fixture.dispose(); - } - }); - - it("keeps worker delivery queued when lane fallback is ambiguous across multiple active chat sessions", async () => { - const sendMessage = vi.fn().mockResolvedValue(undefined); - const agentChatService = createMockAgentChatService({ - sendMessage, - listSessions: vi.fn().mockResolvedValue([ - { - sessionId: "worker-chat-a", - laneId: "lane-1", - provider: "codex", - model: "gpt-5.3-codex", - status: "idle", - startedAt: new Date().toISOString(), - endedAt: null, - lastActivityAt: new Date().toISOString(), - lastOutputPreview: null, - summary: null - }, - { - sessionId: "worker-chat-b", - laneId: "lane-1", - provider: "codex", - model: "gpt-5.3-codex", - status: "active", - startedAt: new Date().toISOString(), - endedAt: null, - lastActivityAt: new Date().toISOString(), - lastOutputPreview: null, - summary: null - } - ]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Keep queued when multiple possible worker chat sessions exist.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Do not misdeliver this directive.", - target: { - kind: "worker", - sessionId: "legacy-orchestrated-session-99", - laneId: fixture.laneId, - stepKey: "step-ambiguous" - } - }); - - await waitFor(() => { - const updated = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - if (!updated) return false; - if (updated.deliveryState !== "queued") return false; - const workerDelivery = ((updated.metadata ?? {}) as { workerDelivery?: { lastError?: string } }).workerDelivery; - return typeof workerDelivery?.lastError === "string" && workerDelivery.lastError.includes("Multiple active worker sessions"); - }); - - expect(sendMessage).not.toHaveBeenCalled(); - } finally { - fixture.dispose(); - } - }); - - it("falls back to steer when worker delivery hits an active-turn conflict", async () => { - const sendMessage = vi - .fn() - .mockRejectedValueOnce(new Error("A turn is already active. Use steer or interrupt.")); - const steer = vi.fn().mockResolvedValue(undefined); - const agentChatService = createMockAgentChatService({ - sendMessage, - steer, - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-busy", status: "idle" }]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Use steer fallback when direct worker delivery is busy.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Keep current diff, but pause on unrelated cleanup.", - target: { - kind: "worker", - sessionId: "worker-session-busy", - stepKey: "step-busy" - } - }); - - await waitFor(() => { - const updated = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return updated?.deliveryState === "delivered"; - }); - - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(steer).toHaveBeenCalledTimes(1); - expect(steer).toHaveBeenCalledWith({ - sessionId: "worker-session-busy", - text: "Keep current diff, but pause on unrelated cleanup." - }); - } finally { - fixture.dispose(); - } - }); - - it("replays queued worker guidance during startup reconciliation", async () => { - const fixture = await createFixture(); - let restartedService: ReturnType | null = null; - try { - const mission = fixture.missionService.create({ - prompt: "Replay queued worker guidance after orchestrator restart.", - laneId: fixture.laneId - }); - - // Start a mission run so hasRecoverableRuntimeWork() returns true on restart - await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - - const queued = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Queue this guidance until worker runtime is ready.", - target: { - kind: "worker", - sessionId: "worker-session-replay", - stepKey: "step-replay" - } - }); - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: queued.threadId ?? "" }) - .find((entry) => entry.id === queued.id); - return current?.deliveryState === "queued"; - }); - - fixture.aiOrchestratorService.dispose(); - const replayAgent = createMockAgentChatService({ - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-replay", status: "idle" }]) - }); - restartedService = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - agentChatService: replayAgent, - projectRoot: fixture.projectRoot - }); - - await waitFor(() => { - const current = restartedService - ?.getThreadMessages({ missionId: mission.id, threadId: queued.threadId ?? "" }) - .find((entry) => entry.id === queued.id); - return current?.deliveryState === "delivered"; - }); - - expect(replayAgent.sendMessage).toHaveBeenCalledWith({ - sessionId: "worker-session-replay", - text: "Queue this guidance until worker runtime is ready." - }); - } finally { - restartedService?.dispose(); - fixture.dispose(); - } - }); - - it("ignores legacy metadata chat entries without message IDs during startup reconciliation", async () => { - const fixture = await createFixture(); - let restartedService: ReturnType | null = null; - try { - const mission = fixture.missionService.create({ - prompt: "Legacy metadata entries without IDs should not be imported.", - laneId: fixture.laneId - }); - - // Start a mission run so hasRecoverableRuntimeWork() returns true on restart - await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" - }); - - const legacyContent = "legacy-backfill-without-id"; - const missionThreadId = `mission:${mission.id}`; - fixture.db.run( - ` - update missions - set metadata_json = ? - where id = ? - `, - [ - JSON.stringify({ - orchestratorChat: [ - { - role: "user", - content: legacyContent, - timestamp: "2026-02-20T00:00:00.000Z", - target: { - kind: "coordinator", - runId: null - } - } - ] - }), - mission.id - ] - ); - - fixture.aiOrchestratorService.dispose(); - const restart = () => - createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - projectRoot: fixture.projectRoot - }); - - restartedService = restart(); - await waitFor(() => { - const thread = fixture.db.get<{ id: string }>( - ` - select id - from orchestrator_chat_threads - where mission_id = ? - and id = ? - `, - [mission.id, missionThreadId] - ); - return Boolean(thread?.id); - }); - const firstCount = fixture.db.get<{ count: number }>( - ` - select count(1) as count - from orchestrator_chat_messages - where mission_id = ? - and content = ? - `, - [mission.id, legacyContent] - ); - expect(firstCount?.count).toBe(0); - restartedService.dispose(); - - restartedService = restart(); - await waitFor(() => { - const thread = fixture.db.get<{ id: string }>( - ` - select id - from orchestrator_chat_threads - where mission_id = ? - and id = ? - `, - [mission.id, missionThreadId] - ); - return Boolean(thread?.id); - }); - - const finalCount = fixture.db.get<{ count: number }>( - ` - select count(1) as count - from orchestrator_chat_messages - where mission_id = ? - and content = ? - `, - [mission.id, legacyContent] - ); - expect(finalCount?.count).toBe(0); - } finally { - restartedService?.dispose(); - fixture.dispose(); - } - }); - - it("hydrates coordinator chat threads from real agent chat sessions", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Show the coordinator through the normal ADE chat pane.", - laneId: fixture.laneId, - }); - const threadId = `mission:${mission.id}`; - fixture.db.run( - ` - insert into orchestrator_chat_threads( - id, project_id, mission_id, thread_type, title, run_id, - session_id, lane_id, status, unread_count, created_at, updated_at - ) values (?, ?, ?, 'coordinator', 'Orchestrator', ?, null, ?, 'closed', 0, ?, ?) - `, - [ - threadId, - "proj-1", - mission.id, - "run-1", - fixture.laneId, - "2026-02-20T00:00:00.000Z", - "2026-02-20T00:00:03.000Z", - ], - ); - fixture.db.run( - ` - insert into orchestrator_chat_messages( - id, project_id, mission_id, thread_id, role, content, timestamp, - target_json, visibility, delivery_state, source_session_id, lane_id, run_id, metadata_json, created_at - ) values (?, ?, ?, ?, 'orchestrator', ?, ?, ?, 'full', 'delivered', ?, ?, ?, ?, ?) - `, - [ - "coordinator-message-real-session", - "proj-1", - mission.id, - threadId, - "The coordinator wrote through AgentChat.", - "2026-02-20T00:00:01.000Z", - JSON.stringify({ kind: "coordinator", runId: "run-1" }), - "coordinator-chat-session-1", - fixture.laneId, - "run-1", - JSON.stringify({ source: "agent_chat_event" }), - "2026-02-20T00:00:01.000Z", - ], - ); - fixture.db.run( - ` - insert into orchestrator_chat_messages( - id, project_id, mission_id, thread_id, role, content, timestamp, - target_json, visibility, delivery_state, source_session_id, lane_id, run_id, metadata_json, created_at - ) values (?, ?, ?, ?, 'orchestrator', ?, ?, ?, 'full', 'delivered', ?, ?, ?, ?, ?) - `, - [ - "coordinator-message-synthetic-session", - "proj-1", - mission.id, - threadId, - "Synthetic lifecycle events should not masquerade as chats.", - "2026-02-20T00:00:02.000Z", - JSON.stringify({ kind: "coordinator", runId: "run-1" }), - "coordinator:run-1", - fixture.laneId, - "run-1", - JSON.stringify({ source: "agent_chat_event" }), - "2026-02-20T00:00:02.000Z", - ], - ); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.id === threadId); - - expect(thread?.sessionId).toBe("coordinator-chat-session-1"); - expect(thread?.laneId).toBe(fixture.laneId); - } finally { - fixture.dispose(); - } - }); - - it("preserves per-thread ordering while replaying queued worker guidance on runtime signals", async () => { - const sendMessage = vi - .fn() - .mockRejectedValueOnce(new Error("Temporary transport failure.")) - .mockResolvedValue(undefined); - const agentChatService = createMockAgentChatService({ - sendMessage, - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-order", status: "idle" }]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Replay queued worker guidance deterministically in-order.", - laneId: fixture.laneId - }); - - const first = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "first guidance", - target: { - kind: "worker", - sessionId: "worker-session-order", - stepKey: "step-order" - } - }); - const second = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - threadId: first.threadId, - content: "second guidance", - target: { - kind: "worker", - sessionId: "worker-session-order", - stepKey: "step-order" - } - }); - - await waitFor(() => { - const entries = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: first.threadId ?? "" - }); - const firstState = entries.find((entry) => entry.id === first.id)?.deliveryState; - const secondState = entries.find((entry) => entry.id === second.id)?.deliveryState; - return firstState === "queued" && secondState === "queued"; - }); - - await waitFor(() => sendMessage.mock.calls.length >= 1); - expect(sendMessage).toHaveBeenCalledTimes(1); - - clearWorkerDeliveryBackoff(fixture.db, first.id); - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId: "worker-session-order", - runtimeState: "running", - lastOutputPreview: "worker online", - at: new Date().toISOString() - }); - - await waitFor(() => { - const entries = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: first.threadId ?? "" - }); - const firstState = entries.find((entry) => entry.id === first.id)?.deliveryState; - const secondState = entries.find((entry) => entry.id === second.id)?.deliveryState; - return firstState === "delivered" && secondState === "delivered"; - }); - - const texts = sendMessage.mock.calls.map((call) => call[0]?.text); - expect(texts).toEqual(["first guidance", "first guidance", "second guidance"]); - } finally { - fixture.dispose(); - } - }); - - it("marks worker guidance as failed after retry budget and opens a recovery intervention", async () => { - const sendMessage = vi.fn().mockRejectedValue(new Error("Worker runtime unavailable.")); - const agentChatService = createMockAgentChatService({ - sendMessage, - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-fail", status: "idle" }]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Fail queued guidance after bounded retries.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "This guidance should eventually fail delivery.", - target: { - kind: "worker", - sessionId: "worker-session-fail", - stepKey: "step-fail" - } - }); - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return current?.deliveryState === "queued"; - }); - - for (let i = 0; i < 6; i += 1) { - clearWorkerDeliveryBackoff(fixture.db, sent.id); - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId: "worker-session-fail", - runtimeState: "running", - lastOutputPreview: `replay-${i}`, - at: new Date().toISOString() - }); - const done = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id)?.deliveryState; - if (done === "failed") break; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return current?.deliveryState === "failed"; - }); - - const refreshedMission = fixture.missionService.get(mission.id); - expect( - refreshedMission?.interventions.some( - (entry) => - entry.status === "open" - && entry.interventionType === "manual_input" - && String(entry.metadata?.sourceMessageId ?? "") === sent.id - ) - ).toBe(true); - expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(4); - } finally { - fixture.dispose(); - } - }); - - it("replays queued worker guidance when agent chat reports turn completion", async () => { - const sendMessage = vi - .fn() - .mockRejectedValueOnce(new Error("Temporary transport failure.")) - .mockResolvedValue(undefined); - const agentChatService = createMockAgentChatService({ - sendMessage, - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-agent-event", status: "idle" }]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Replay queued messages on agent-chat turn completion events.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Replay me on agent-chat completion.", - target: { - kind: "worker", - sessionId: "worker-session-agent-event", - stepKey: "step-agent-event" - } - }); - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return current?.deliveryState === "queued"; - }); - - clearWorkerDeliveryBackoff(fixture.db, sent.id); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: "worker-session-agent-event", - timestamp: new Date().toISOString(), - event: { - type: "status", - turnStatus: "completed" - } - }); - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return current?.deliveryState === "delivered"; - }); - - expect(sendMessage).toHaveBeenCalledTimes(2); - } finally { - fixture.dispose(); - } - }); - - it("replays manual coordinator chat mission-control JSON through real tools", async () => { - const coordinatorSessionId = "coordinator-session-manual-replay"; - const agentChatService = createMockAgentChatService({ - createSession: vi.fn().mockResolvedValue({ id: coordinatorSessionId }), - runSessionTurn: vi.fn().mockResolvedValue({ outputText: "Coordinator is standing by.", turnId: "runtime-turn-1" }), - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Exercise manual coordinator chat replay.", - laneId: fixture.laneId, - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - if (!launch.started?.run.id) throw new Error("Expected mission run to start"); - - await waitFor(() => agentChatService.runSessionTurn.mock.calls.length > 0); - await agentChatService.runSessionTurn.mock.results[0]?.value; - await new Promise((resolve) => setTimeout(resolve, 0)); - - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: new Date().toISOString(), - event: { - type: "text", - text: [ - "```json", - "// get_project_context", - "{}", - "```", - ].join("\n"), - turnId: "manual-turn-1", - itemId: "manual-text-1", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: new Date().toISOString(), - event: { - type: "done", - status: "completed", - turnId: "manual-turn-1", - model: "sonnet", - }, - }); - - let coordinatorMessages: string[] = []; - await waitFor(() => { - coordinatorMessages = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: `mission:${mission.id}` }) - .map((message) => message.content); - return coordinatorMessages.some((content) => content === "Tool call: get_project_context"); - }).catch((error) => { - throw new Error(`${error instanceof Error ? error.message : String(error)}; coordinator messages: ${coordinatorMessages.join(" | ")}`); - }); - } finally { - fixture.dispose(); - } - }); - - it("hydrates the coordinator runtime before replaying persisted manual coordinator chat", async () => { - const coordinatorSessionId = "coordinator-session-restart-replay"; - const agentChatService = createMockAgentChatService({ - createSession: vi.fn().mockResolvedValue({ id: coordinatorSessionId }), - runSessionTurn: vi.fn().mockResolvedValue({ outputText: "Coordinator is standing by.", turnId: "runtime-turn-1" }), - }); - const fixture = await createFixture({ agentChatService }); - let restartedService: ReturnType | null = null; - try { - const mission = fixture.missionService.create({ - prompt: "Replay persisted coordinator chat after restart.", - laneId: fixture.laneId, - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - const runId = launch.started?.run.id; - if (!runId) throw new Error("Expected mission run to start"); - - await waitFor(() => agentChatService.runSessionTurn.mock.calls.length > 0); - await agentChatService.runSessionTurn.mock.results[0]?.value; - await new Promise((resolve) => setTimeout(resolve, 0)); - - const threadRow = fixture.db.get<{ session_id: string | null }>( - `select session_id from orchestrator_chat_threads where thread_type = 'coordinator' and run_id = ? limit 1`, - [runId], - ); - expect(threadRow?.session_id).toBe(coordinatorSessionId); - - fixture.aiOrchestratorService.dispose(); - restartedService = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - agentChatService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: fixture.prService, - projectRoot: fixture.projectRoot, - }); - - restartedService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: new Date().toISOString(), - event: { - type: "text", - text: [ - "```json", - "// get_project_context", - "{}", - "```", - ].join("\n"), - turnId: "manual-turn-after-restart", - itemId: "manual-text-after-restart", - }, - }); - restartedService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: new Date().toISOString(), - event: { - type: "done", - status: "completed", - turnId: "manual-turn-after-restart", - model: "sonnet", - }, - }); - - let coordinatorMessages: string[] = []; - await waitFor(() => { - coordinatorMessages = restartedService! - .getThreadMessages({ missionId: mission.id, threadId: `mission:${mission.id}` }) - .map((message) => message.content); - return coordinatorMessages.some((content) => content === "Tool call: get_project_context"); - }).catch((error) => { - throw new Error(`${error instanceof Error ? error.message : String(error)}; coordinator messages: ${coordinatorMessages.join(" | ")}`); - }); - } finally { - restartedService?.dispose(); - fixture.dispose(); - } - }); - - it("hides coordinator raw text deltas and provider-native tool noise", async () => { - const coordinatorSessionId = "coordinator-session-text-deltas"; - const agentChatService = createMockAgentChatService({ - createSession: vi.fn().mockResolvedValue({ id: coordinatorSessionId }), - runSessionTurn: vi.fn().mockResolvedValue({ outputText: "Coordinator is standing by.", turnId: "runtime-turn-1" }), - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Persist streamed coordinator text cleanly.", - laneId: fixture.laneId, - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "autopilot", - defaultExecutorKind: "opencode", - }); - if (!launch.started?.run.id) throw new Error("Expected mission run to start"); - - await waitFor(() => agentChatService.runSessionTurn.mock.calls.length > 0); - await agentChatService.runSessionTurn.mock.results[0]?.value; - await new Promise((resolve) => setTimeout(resolve, 0)); - - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:01.000Z", - event: { type: "text", text: "Hello ", turnId: "delta-turn-1", itemId: "text-1" }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:02.000Z", - event: { type: "text", text: "world.", turnId: "delta-turn-1", itemId: "text-1" }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:03.000Z", - event: { type: "tool_call", tool: "Write", args: {}, turnId: "delta-turn-1", itemId: "native-write" }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:04.000Z", - event: { - type: "tool_result", - tool: "Write", - result: { synthetic: true }, - turnId: "delta-turn-1", - itemId: "native-write", - status: "completed", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:05.000Z", - event: { - type: "text", - text: "```json", - turnId: "delta-turn-1", - itemId: "pseudo-tool-fragment", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:06.000Z", - event: { - type: "text", - text: "// get_project_context", - turnId: "delta-turn-1", - itemId: "pseudo-tool-label", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:07.000Z", - event: { - type: "text", - text: "```json\n// spawn_worker\n{\"stepKey\":\"worker-1\"}\n```", - turnId: "delta-turn-1", - itemId: "pseudo-tool-text", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId: coordinatorSessionId, - timestamp: "2026-02-20T00:00:08.000Z", - event: { - type: "status", - turnStatus: "interrupted", - message: "The coordinator wrote mission-control calls as text, so ADE is replaying those calls through the real mission-control tools.", - turnId: "delta-turn-1", - }, - }); - - const messages = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: `mission:${mission.id}` }) - .map((message) => message.content); - expect(messages).not.toContain("Hello world."); - expect(messages).not.toContain("Tool call: Write"); - expect(messages.some((content) => content.includes("```json"))).toBe(false); - expect(messages.some((content) => content.includes("// get_project_context"))).toBe(false); - expect(messages.some((content) => content.includes("// spawn_worker"))).toBe(false); - expect(messages.some((content) => content.includes("replaying those calls"))).toBe(false); - - const rows = fixture.db.all<{ content: string }>( - `select content from orchestrator_chat_messages where mission_id = ? order by created_at`, - [mission.id], - ); - expect(rows.map((row) => row.content)).not.toContain("Hello world."); - expect(rows.some((row) => row.content === "Hello ")).toBe(false); - expect(rows.some((row) => row.content.includes("Write"))).toBe(false); - expect(rows.some((row) => row.content.includes("```json"))).toBe(false); - expect(rows.some((row) => row.content.includes("// get_project_context"))).toBe(false); - expect(rows.some((row) => row.content.includes("// spawn_worker"))).toBe(false); - expect(rows.some((row) => row.content.includes("replaying those calls"))).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("persists structured worker chat events into the worker thread", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Show worker tool calls and reasoning in the worker chat thread.", - laneId: fixture.laneId, - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual", - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - clearRunPhaseConfig(fixture.db, runId); - - fixture.orchestratorService.addSteps({ - runId, - steps: [{ stepKey: "implement-structured-chat", title: "Implement structured chat", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }], - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((step) => step.status === "ready"); - if (!readyStep) throw new Error("Expected a ready step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - const sessionId = "worker-session-structured-chat"; - fixture.db.run( - ` - update orchestrator_attempts - set executor_kind = 'opencode', - executor_session_id = ? - where id = ? - `, - [sessionId, attempt.id], - ); - - fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ - type: "orchestrator-attempt-updated", - runId, - stepId: readyStep.id, - attemptId: attempt.id, - reason: "session_attached", - at: new Date().toISOString(), - }); - - await waitFor(() => { - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - return Boolean(thread); - }); - - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "reasoning", - text: "Reviewing the sidebar registration flow.", - turnId: "turn-1", - itemId: "reasoning-1", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "tool_call", - tool: "read_file", - args: { path: "apps/desktop/src/App.tsx" }, - itemId: "tool-1", - turnId: "turn-1", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "tool_result", - tool: "read_file", - result: { ok: true, bytes: 1234 }, - itemId: "tool-1", - turnId: "turn-1", - status: "completed", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "text", - text: "I found the sidebar entry point.", - turnId: "turn-1", - itemId: "assistant-1", - }, - }); - - await waitFor(() => { - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - if (!thread) return false; - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread.id, - }); - return messages.some((entry) => String(entry.metadata?.source ?? "") === "agent_chat_event"); - }); - - const thread = fixture.aiOrchestratorService - .listChatThreads({ missionId: mission.id, includeClosed: true }) - .find((entry) => entry.attemptId === attempt.id); - if (!thread) throw new Error("Expected worker thread"); - const messages = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread.id, - }); - const structuredMessages = messages.filter((entry) => String(entry.metadata?.source ?? "") === "agent_chat_event"); - - expect(structuredMessages.some((entry) => entry.content.includes("Reviewing the sidebar registration flow."))).toBe(true); - expect( - structuredMessages.some((entry) => { - const stream = entry.metadata && typeof entry.metadata === "object" && !Array.isArray(entry.metadata) - ? (entry.metadata as Record).structuredStream as Record | undefined - : undefined; - return stream?.kind === "tool" - && stream.tool === "read_file" - && (stream.status === "completed") - && typeof JSON.stringify(stream.result ?? null) === "string"; - }), - ).toBe(true); - expect(structuredMessages.some((entry) => entry.content.includes("I found the sidebar entry point."))).toBe(true); - - const structuredCountBeforeNoise = structuredMessages.length; - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "status", - turnStatus: "started", - message: "Turn started.", - turnId: "turn-2", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "activity", - activity: "running_command", - detail: "/bin/zsh -lc 'sleep 20'", - turnId: "turn-2", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "command", - command: "/bin/zsh -lc 'sleep 20'", - cwd: fixture.projectRoot, - output: "", - itemId: "command-noise-1", - turnId: "turn-2", - status: "running", - }, - }); - fixture.aiOrchestratorService.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { - type: "done", - status: "completed", - turnId: "turn-2", - model: "gpt-5.5", - }, - }); - - const messagesAfterNoise = fixture.aiOrchestratorService.getThreadMessages({ - missionId: mission.id, - threadId: thread.id, - }).filter((entry) => String(entry.metadata?.source ?? "") === "agent_chat_event"); - expect(messagesAfterNoise).toHaveLength(structuredCountBeforeNoise); - } finally { - fixture.dispose(); - } - }); - - it("preserves delivery idempotence by holding in-flight messages then failing stale in-flight attempts", async () => { - const sendMessage = vi.fn().mockRejectedValue(new Error("Transport unavailable.")); - const agentChatService = createMockAgentChatService({ - sendMessage, - listSessions: vi.fn().mockResolvedValue([{ sessionId: "worker-session-inflight", status: "idle" }]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Guard against duplicate injection while delivery is in-flight.", - laneId: fixture.laneId - }); - - const sent = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "In-flight guard test guidance.", - target: { - kind: "worker", - sessionId: "worker-session-inflight", - stepKey: "step-inflight" - } - }); - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return current?.deliveryState === "queued"; - }); - - const initialCalls = sendMessage.mock.calls.length; - patchWorkerDeliveryMetadata(fixture.db, sent.id, { - inFlightAttemptId: `${sent.id}:attempt:1`, - inFlightAt: new Date().toISOString(), - inFlightSessionId: "worker-session-inflight", - nextRetryAt: null - }); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId: "worker-session-inflight", - runtimeState: "running", - lastOutputPreview: "still running", - at: new Date().toISOString() - }); - - await new Promise((resolve) => setTimeout(resolve, 120)); - expect(sendMessage.mock.calls.length).toBe(initialCalls); - - patchWorkerDeliveryMetadata(fixture.db, sent.id, { - inFlightAttemptId: `${sent.id}:attempt:1`, - inFlightAt: "2000-01-01T00:00:00.000Z", - inFlightSessionId: "worker-session-inflight", - nextRetryAt: null - }); - - fixture.aiOrchestratorService.onSessionRuntimeSignal({ - laneId: fixture.laneId, - sessionId: "worker-session-inflight", - runtimeState: "running", - lastOutputPreview: "resume", - at: new Date().toISOString() - }); - - await waitFor(() => { - const current = fixture.aiOrchestratorService - .getThreadMessages({ missionId: mission.id, threadId: sent.threadId ?? "" }) - .find((entry) => entry.id === sent.id); - return current?.deliveryState === "failed"; - }); - - const refreshedMission = fixture.missionService.get(mission.id); - expect( - refreshedMission?.interventions.some( - (entry) => - entry.status === "open" - && entry.interventionType === "manual_input" - && String(entry.metadata?.sourceMessageId ?? "") === sent.id - ) - ).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("supports broadcast worker targeting and fans out guidance to matching worker threads", async () => { - const sendMessage = vi.fn().mockResolvedValue(undefined); - const agentChatService = createMockAgentChatService({ - sendMessage, - listSessions: vi.fn().mockImplementation(async () => [ - { sessionId: "worker-session-a", status: "idle" }, - { sessionId: "worker-session-b", status: "idle" } - ]) - }); - const fixture = await createFixture({ agentChatService }); - try { - const mission = fixture.missionService.create({ - prompt: "Broadcast one directive to all workers.", - laneId: fixture.laneId - }); - - const workerA = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "bootstrap worker A", - target: { - kind: "worker", - sessionId: "worker-session-a", - stepKey: "step-a" - } - }); - const workerB = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "bootstrap worker B", - target: { - kind: "worker", - sessionId: "worker-session-b", - stepKey: "step-b" - } - }); - - await waitFor(() => { - const messagesA = fixture.aiOrchestratorService.getThreadMessages({ missionId: mission.id, threadId: workerA.threadId ?? "" }); - const messagesB = fixture.aiOrchestratorService.getThreadMessages({ missionId: mission.id, threadId: workerB.threadId ?? "" }); - return messagesA.some((entry) => entry.id === workerA.id && entry.deliveryState === "delivered") - && messagesB.some((entry) => entry.id === workerB.id && entry.deliveryState === "delivered"); - }); - - sendMessage.mockClear(); - - const broadcast = fixture.aiOrchestratorService.sendThreadMessage({ - missionId: mission.id, - content: "Apply logging guardrails before continuing implementation.", - target: { - kind: "workers" - } - }); - - expect(broadcast.target?.kind).toBe("workers"); - - await waitFor(() => sendMessage.mock.calls.length >= 2); - const callTexts = sendMessage.mock.calls.map((call) => String(call[0]?.text ?? "")); - expect(callTexts).toEqual([ - "Apply logging guardrails before continuing implementation.", - "Apply logging guardrails before continuing implementation." - ]); - - const messagesA = fixture.aiOrchestratorService.getThreadMessages({ missionId: mission.id, threadId: workerA.threadId ?? "" }); - const messagesB = fixture.aiOrchestratorService.getThreadMessages({ missionId: mission.id, threadId: workerB.threadId ?? "" }); - const broadcastA = messagesA.find((entry) => entry.content.includes("Apply logging guardrails")); - const broadcastB = messagesB.find((entry) => entry.content.includes("Apply logging guardrails")); - const metadataA = (broadcastA?.metadata ?? null) as { workerBroadcast?: { sourceMessageId?: string } } | null; - const metadataB = (broadcastB?.metadata ?? null) as { workerBroadcast?: { sourceMessageId?: string } } | null; - expect(String(metadataA?.workerBroadcast?.sourceMessageId ?? "")).toBe(broadcast.id); - expect(String(metadataB?.workerBroadcast?.sourceMessageId ?? "")).toBe(broadcast.id); - } finally { - fixture.dispose(); - } - }); - - it("completes multi-lane run and assembles a result lane via prService", async () => { - const fixture = await createFixture(); - try { - const prServiceMock = { - createIntegrationLane: vi.fn().mockImplementation(async () => { - const laneNow = new Date().toISOString(); - fixture.db.run( - `insert or ignore into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - "result-lane-1", - fixture.projectId, - "mission/build-feature-with-two-workers-result", - "Synthetic result lane for aiOrchestratorService.test.ts", - "worktree", - "main", - "integration/result-lane-1", - fixture.projectRoot, - null, - 0, - fixture.laneId, - null, - null, - null, - null, - mission.id, - "result", - "active", - laneNow, - null, - ], - ); - return { - integrationLane: { - id: "result-lane-1", - name: "mission/build-feature-with-two-workers-result", - laneType: "worktree", - branchRef: "integration/result-lane-1", - worktreePath: fixture.projectRoot, - missionId: mission.id, - laneRole: "result", - }, - mergeResults: [ - { laneId: "lane-a", success: true }, - { laneId: "lane-b", success: true }, - ], - }; - }), - } as any; - const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default phase profile"); - // Create a mission with steps on different lanes - const mission = fixture.missionService.create({ - prompt: "Build feature with two workers.", - laneId: fixture.laneId, - phaseProfileId: defaultProfile.id, - phaseOverride: defaultProfile.phases.map((phase, index) => ({ - ...phase, - position: index, - validationGate: { ...phase.validationGate, tier: "none", required: false, criteria: undefined, evidenceRequirements: undefined }, - })), - plannedSteps: [ - { index: 0, title: "Worker A task", detail: "Task A", kind: "implementation", metadata: { stepType: "implementation" } }, - { index: 0, title: "Worker B task", detail: "Task B", kind: "implementation", metadata: { stepType: "implementation" } } - ] - }); - - // Preserve a legacy metadata payload to verify closeout ignores it. - const existingMeta = JSON.parse( - fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [mission.id] - )?.metadata_json ?? "{}" - ); - existingMeta.executionPolicy = { - ...existingMeta.executionPolicy, - prStrategy: { kind: "integration" }, - integrationPr: { enabled: true, draft: true, autoResolveConflicts: false } - }; - existingMeta.missionLevelSettings = { - prStrategy: { kind: "integration" }, - integrationPr: { enabled: true, createIntegrationLane: false, prDepth: "shallow", draft: true } - }; - fixture.db.run( - `update missions set metadata_json = ? where id = ?`, - [JSON.stringify(existingMeta), mission.id] - ); - - // Manually add prService to the ai orchestrator by recreating it with prService - const logMessages: string[] = []; - const captureLogger = { - debug: (msg: string) => logMessages.push(`debug: ${msg}`), - info: (msg: string) => logMessages.push(`info: ${msg}`), - warn: (msg: string) => logMessages.push(`warn: ${msg}`), - error: (msg: string) => logMessages.push(`error: ${msg}`) - } as any; - const aiOrchestratorWithPr = createAiOrchestratorService({ - db: fixture.db, - logger: captureLogger, - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: prServiceMock, - projectRoot: fixture.projectRoot - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { stepKey: "worker-a-task", title: "Worker A task", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { stepType: "implementation", instructions: "Task A" } }, - { stepKey: "worker-b-task", title: "Worker B task", stepIndex: 1, dependencyStepKeys: [], executorKind: "manual", metadata: { stepType: "implementation", instructions: "Task B" } } - ] - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - // Transition mission to in_progress so syncMissionFromRun can transition to completed - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const runId = started.run.id; - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - - // Insert synthetic lane rows so FK constraints on orchestrator_steps.lane_id are satisfied - const laneNow = new Date().toISOString(); - for (const lid of ["lane-a", "lane-b"]) { - fixture.db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - lid, fixture.projectId, lid, null, "worktree", "main", - `feature/${lid}`, fixture.projectRoot, null, 0, fixture.laneId, - null, null, null, "active", laneNow, null - ] - ); - } - - // Assign different laneIds to steps via DB to simulate multi-lane run - for (const step of graph.steps) { - const laneId = step.title === "Worker A task" ? "lane-a" : "lane-b"; - fixture.db.run( - `update orchestrator_steps set lane_id = ? where id = ?`, - [laneId, step.id] - ); - } - - // Complete all steps, ticking between each to advance readiness - let remaining = graph.steps.length; - while (remaining > 0) { - fixture.orchestratorService.tick({ runId }); - const currentGraph = fixture.orchestratorService.getRunGraph({ runId }); - const readySteps = currentGraph.steps.filter((s) => s.status === "ready"); - if (readySteps.length === 0) break; - for (const step of readySteps) { - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: step.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Done", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - remaining--; - } - } - fixture.orchestratorService.tick({ runId }); - const finalizeResult = aiOrchestratorWithPr.finalizeRun({ runId }); - expect(finalizeResult.finalized).toBe(true); - - // Verify run is actually completed - const finalGraph = fixture.orchestratorService.getRunGraph({ runId }); - expect(finalGraph.run.status).toBe("succeeded"); - - // Verify steps have different lane IDs - const uniqueLaneIds = new Set(finalGraph.steps.map((s) => s.laneId).filter(Boolean)); - expect(uniqueLaneIds.size).toBeGreaterThan(1); - - // Directly call syncMissionFromRun and await it - await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - // Verify mission became completed - const missionAfterSync = fixture.missionService.get(mission.id); - expect(missionAfterSync?.status).toBe("completed"); - - expect(prServiceMock.createIntegrationLane).toHaveBeenCalledWith(expect.objectContaining({ - sourceLaneIds: expect.arrayContaining(["lane-a", "lane-b"]), - missionId: mission.id, - laneRole: "result", - })); - expect(missionAfterSync?.resultLaneId).toBe("result-lane-1"); - - aiOrchestratorWithPr.dispose(); - } finally { - fixture.dispose(); - } - }); - - it("keeps blocked result-lane finalization from being reclassified as completed", async () => { - const fixture = await createFixture(); - try { - const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default phase profile"); - const mission = fixture.missionService.create({ - prompt: "Complete work but keep result-lane conflicts surfaced.", - laneId: fixture.laneId, - phaseProfileId: defaultProfile.id, - phaseOverride: defaultProfile.phases.map((phase, index) => ({ - ...phase, - position: index, - validationGate: { - ...phase.validationGate, - tier: "none", - required: false, - criteria: undefined, - evidenceRequirements: undefined, - }, - })), - plannedSteps: [ - { index: 0, title: "Worker task", detail: "Task", kind: "implementation", metadata: { stepType: "implementation" } }, - ], - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "worker-task", - title: "Worker task", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation", instructions: "Do the work" }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const runId = started.run.id; - - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((entry) => entry.status === "ready") ?? graph.steps[0]; - if (!readyStep) throw new Error("Expected mission step"); - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Done", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - fixture.orchestratorService.tick({ runId }); - expect(fixture.aiOrchestratorService.finalizeRun({ runId }).finalized).toBe(true); - - await updateMissionStateDocument({ - projectRoot: fixture.projectRoot, - missionId: mission.id, - runId, - goal: mission.prompt, - patch: { - finalization: { - policy: { - kind: "result_lane", - targetBranch: null, - draft: null, - prDepth: null, - autoRebase: null, - ciGating: null, - autoLand: null, - autoResolveConflicts: null, - archiveLaneOnLand: null, - mergeMethod: null, - conflictResolverModel: null, - reasoningEffort: null, - description: "Assemble a single result lane for the mission and stop before external PR creation.", - }, - status: "finalizing", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: "lane-a: merge conflict", - summary: "Result lane assembly is blocked by merge conflicts.", - detail: "lane-a: merge conflict", - resolverJobId: null, - integrationLaneId: null, - resultLaneId: "result-lane-conflict", - queueGroupId: null, - queueId: null, - activePrId: null, - waitReason: null, - proposalUrl: null, - prUrls: [], - reviewStatus: null, - mergeReadiness: null, - requirements: [], - warnings: ["lane-a: merge conflict"], - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - completedAt: null, - }, - }, - }); - fixture.missionService.update({ - missionId: mission.id, - status: "intervention_required", - lastError: "Result lane merge conflict: result-lane-conflict", - }); - - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("intervention_required"); - const stateDoc = await readMissionStateDocument({ projectRoot: fixture.projectRoot, runId }); - expect(stateDoc?.finalization?.status).toBe("finalizing"); - expect(stateDoc?.finalization?.blocked).toBe(true); - expect(stateDoc?.finalization?.contractSatisfied).toBe(false); - expect(stateDoc?.finalization?.summary).toBe("Result lane assembly is blocked by merge conflicts."); - } finally { - fixture.dispose(); - } - }); - - it("fails mission closeout when result-lane assembly throws", async () => { - const prServiceMock = { - createIntegrationLane: vi.fn().mockRejectedValue(new Error("GitHub API rate limit exceeded")) - } as any; - - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Build feature with two workers.", - laneId: fixture.laneId, - plannedSteps: [ - { index: 0, title: "Worker A", detail: "A", kind: "implementation", metadata: { stepType: "implementation" } }, - { index: 0, title: "Worker B", detail: "B", kind: "implementation", metadata: { stepType: "implementation" } } - ] - }); - - // Preserve a legacy metadata payload to verify closeout ignores it. - const existingMeta2 = JSON.parse( - fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [mission.id] - )?.metadata_json ?? "{}" - ); - existingMeta2.executionPolicy = { - ...existingMeta2.executionPolicy, - prStrategy: { kind: "integration", targetBranch: "main", draft: true }, - integrationPr: { enabled: true, draft: true, autoResolveConflicts: false } - }; - existingMeta2.missionLevelSettings = { - ...(existingMeta2.missionLevelSettings ?? {}), - prStrategy: { kind: "integration", targetBranch: "main", draft: true }, - }; - fixture.db.run( - `update missions set metadata_json = ? where id = ?`, - [JSON.stringify(existingMeta2), mission.id] - ); - - const aiOrchestratorWithPr = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: prServiceMock, - projectRoot: fixture.projectRoot - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { stepKey: "worker-a", title: "Worker A", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { stepType: "implementation" } }, - { stepKey: "worker-b", title: "Worker B", stepIndex: 1, dependencyStepKeys: [], executorKind: "manual", metadata: { stepType: "implementation" } } - ] - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - // Transition mission to in_progress so syncMissionFromRun can transition to failed - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const runId = started.run.id; - - // Assign different laneIds and complete all steps sequentially - { - // Insert synthetic lane rows so FK constraints on orchestrator_steps.lane_id are satisfied - const laneNow2 = new Date().toISOString(); - for (const lid of ["lane-a", "lane-b"]) { - fixture.db.run( - `insert or ignore into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - lid, fixture.projectId, lid, null, "worktree", "main", - `feature/${lid}`, fixture.projectRoot, null, 0, fixture.laneId, - null, null, null, "active", laneNow2, null - ] - ); - } - - fixture.orchestratorService.tick({ runId }); - const initialGraph = fixture.orchestratorService.getRunGraph({ runId }); - for (const step of initialGraph.steps) { - const lid = step.title === "Worker A" ? "lane-a" : "lane-b"; - fixture.db.run(`update orchestrator_steps set lane_id = ? where id = ?`, [lid, step.id]); - } - let rem = initialGraph.steps.length; - while (rem > 0) { - fixture.orchestratorService.tick({ runId }); - const g = fixture.orchestratorService.getRunGraph({ runId }); - const ready = g.steps.filter((s) => s.status === "ready"); - if (ready.length === 0) break; - for (const step of ready) { - const attempt = await fixture.orchestratorService.startAttempt({ - runId, stepId: step.id, ownerId: "test-owner", executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, status: "succeeded", - result: { schema: "ade.orchestratorAttempt.v1", success: true, summary: "Done", outputs: null, warnings: [], sessionId: null, trackedSession: false } - }); - rem--; - } - } - fixture.orchestratorService.tick({ runId }); - } - - const finalizeResult = aiOrchestratorWithPr.finalizeRun({ runId }); - expect(finalizeResult.finalized).toBe(true); - - await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - // Result-lane assembly failure should block mission completion. - const refreshed = fixture.missionService.get(mission.id); - expect(refreshed?.status).toBe("failed"); - - aiOrchestratorWithPr.dispose(); - } finally { - fixture.dispose(); - } - }); - - it("reuses the sole run lane as the result lane for single-lane runs", async () => { - const prServiceMock = { - createIntegrationLane: vi.fn(), - } as any; - - const fixture = await createFixture(); - try { - const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default phase profile"); - const mission = fixture.missionService.create({ - prompt: "Single lane integration PR mission.", - laneId: fixture.laneId, - phaseProfileId: defaultProfile.id, - phaseOverride: defaultProfile.phases.map((phase, index) => ({ - ...phase, - position: index, - validationGate: { ...phase.validationGate, tier: "none", required: false, criteria: undefined, evidenceRequirements: undefined }, - })), - plannedSteps: [ - { index: 0, title: "Worker task", detail: "Task", kind: "implementation", metadata: { stepType: "implementation" } } - ] - }); - - const aiOrchestratorWithPr = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: prServiceMock, - projectRoot: fixture.projectRoot - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "worker-task", - title: "Worker task", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation", instructions: "Do the work" } - } - ] - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const runId = started.run.id; - - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((entry) => entry.status === "ready") ?? graph.steps[0]; - if (!readyStep) throw new Error("Expected mission step"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Done", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - - fixture.orchestratorService.tick({ runId }); - const finalizeResult = aiOrchestratorWithPr.finalizeRun({ runId }); - expect(finalizeResult.finalized).toBe(true); - await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - const missionAfterSync = fixture.missionService.get(mission.id); - expect(missionAfterSync?.status).toBe("completed"); - expect(missionAfterSync?.resultLaneId).toBe(fixture.laneId); - expect(prServiceMock.createIntegrationLane).not.toHaveBeenCalled(); - aiOrchestratorWithPr.dispose(); - } finally { - fixture.dispose(); - } - }); - - it("does not assemble an integration lane for a single-lane run", async () => { - const prServiceMock = { - createIntegrationLane: vi.fn() - } as any; - - const fixture = await createFixture(); - try { - const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); - if (!defaultProfile) throw new Error("Expected default phase profile"); - const mission = fixture.missionService.create({ - prompt: "Build feature.", - laneId: fixture.laneId, - phaseProfileId: defaultProfile.id, - phaseOverride: defaultProfile.phases.map((phase, index) => ({ - ...phase, - position: index, - validationGate: { ...phase.validationGate, tier: "none", required: false, criteria: undefined, evidenceRequirements: undefined }, - })), - plannedSteps: [ - { index: 0, title: "Step 1", detail: "A", kind: "implementation", metadata: { stepType: "implementation" } }, - { index: 1, title: "Step 2", detail: "B", kind: "test", metadata: { stepType: "test" } } - ] - }); - - const existingMeta3 = JSON.parse( - fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [mission.id] - )?.metadata_json ?? "{}" - ); - existingMeta3.executionPolicy = { - ...existingMeta3.executionPolicy, - integrationPr: { enabled: true, draft: true, autoResolveConflicts: false } - }; - fixture.db.run( - `update missions set metadata_json = ? where id = ?`, - [JSON.stringify(existingMeta3), mission.id] - ); - - const aiOrchestratorWithPr = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: prServiceMock, - projectRoot: fixture.projectRoot - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { stepKey: "step-1", title: "Step 1", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { stepType: "implementation" } }, - { stepKey: "step-2", title: "Step 2", stepIndex: 1, dependencyStepKeys: ["step-1"], executorKind: "manual", metadata: { stepType: "test" } } - ] - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); - const runId = started.run.id; - - // Complete all steps sequentially (single lane — no lane_id changes) - { - let rem = 10; // safety bound - while (rem > 0) { - fixture.orchestratorService.tick({ runId }); - const g = fixture.orchestratorService.getRunGraph({ runId }); - const ready = g.steps.filter((s) => s.status === "ready"); - if (ready.length === 0) break; - for (const step of ready) { - const attempt = await fixture.orchestratorService.startAttempt({ - runId, stepId: step.id, ownerId: "test-owner", executorKind: "manual" - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, status: "succeeded", - result: { schema: "ade.orchestratorAttempt.v1", success: true, summary: "Done", outputs: null, warnings: [], sessionId: null, trackedSession: false } - }); - } - rem--; - } - fixture.orchestratorService.tick({ runId }); - } - - const finalizeResult = aiOrchestratorWithPr.finalizeRun({ runId }); - expect(finalizeResult.finalized).toBe(true); - await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - const missionAfterSync = fixture.missionService.get(mission.id); - expect(missionAfterSync?.status).toBe("completed"); - expect(missionAfterSync?.resultLaneId).toBe(fixture.laneId); - expect(prServiceMock.createIntegrationLane).not.toHaveBeenCalled(); - - aiOrchestratorWithPr.dispose(); - } finally { - fixture.dispose(); - } - }); - - it("watchdog detects stalled attempt with no session output and emits warning event", async () => { - const fixture = await createFixture({ - aiIntegrationService: createStagnationRecoveryAiIntegrationService() - }); - try { - // Register a codex adapter that returns "accepted" with a fake session - const sessionId = "session-stalled-1"; - const transcriptPath = path.join(fixture.projectRoot, ".ade", "transcripts", `${sessionId}.log`); - fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); - fs.writeFileSync(transcriptPath, "", "utf8"); - - // Pre-create the terminal_sessions row so the accepted path doesn't fail - fixture.db.run( - ` - insert 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, 'Stalled Worker', ?, null, null, ?, - null, null, 'running', null, null, 'codex-orchestrated', null, null) - `, - [sessionId, fixture.laneId, new Date().toISOString(), transcriptPath] - ); - - fixture.orchestratorService.registerExecutorAdapter({ - kind: "opencode", - start: async () => ({ - status: "accepted" as const, - sessionId, - metadata: { adapterKind: "opencode" } - }) - }); - - const mission = fixture.missionService.create({ - prompt: "Build a feature that may stall during execution.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "opencode" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - // Add steps manually (simulating coordinator creating tasks) - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "implement-changes", - title: "Implement requested changes", - stepIndex: 0, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - instructions: "Do the work", - modelId: "openai/gpt-5.3-codex", - }, - } - ] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.stepKey === "implement-changes" && s.status === "ready"); - if (!readyStep) throw new Error("Expected implement-changes step to be ready"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "opencode" - }); - - // Verify the attempt is running before we backdate - expect(attempt.status).toBe("running"); - expect(attempt.executorSessionId).toBe(sessionId); - - // Backdate the attempt so it appears to have been running for a very long time - // (well past any timeout). This simulates a stalled execution. - fixture.db.run( - ` - update orchestrator_attempts - set started_at = '2000-01-01T00:00:00.000Z', - created_at = '2000-01-01T00:00:00.000Z' - where id = ? - `, - [attempt.id] - ); - - // Also backdate the claims so heartbeat doesn't refresh the activity window - fixture.db.run( - ` - update orchestrator_claims - set heartbeat_at = '2000-01-01T00:00:00.000Z' - where attempt_id = ? - `, - [attempt.id] - ); - - // Backdate the transcript file mtime so it doesn't look like recent activity - const pastTime = new Date("2000-01-01T00:00:00.000Z"); - fs.utimesSync(transcriptPath, pastTime, pastTime); - - // Run the health sweep — the stale attempt should be recovered - await fixture.aiOrchestratorService.runHealthSweep("watchdog_test"); - - // Check the attempt state regardless of sweep count — the health sweep - // may recover via timeout detection OR via session state reconciliation. - const refreshedGraph = fixture.orchestratorService.getRunGraph({ runId }); - const refreshedAttempt = refreshedGraph.attempts.find((a) => a.id === attempt.id); - - // The backdated attempt should have been detected as timed out and failed - expect(refreshedAttempt?.status).toBe("failed"); - expect(refreshedAttempt?.errorClass).toBe("transient"); - expect(refreshedAttempt?.errorMessage ?? "").toContain("stagnating"); - } finally { - fixture.dispose(); - } - }); - - it("skips startup verification warnings for quiet read-only planning workers", async () => { - vi.useFakeTimers(); - const fixture = await createFixture({ - aiIntegrationService: createStagnationRecoveryAiIntegrationService() - }); - try { - const sessionId = "session-planning-quiet-1"; - const transcriptPath = path.join(fixture.projectRoot, ".ade", "transcripts", `${sessionId}.log`); - fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); - fs.writeFileSync(transcriptPath, "", "utf8"); - - fixture.db.run( - ` - insert 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, 'Quiet Planning Worker', ?, null, null, ?, - null, null, 'running', null, null, 'claude-orchestrated', null, null) - `, - [sessionId, fixture.laneId, new Date().toISOString(), transcriptPath] - ); - - fixture.orchestratorService.registerExecutorAdapter({ - kind: "opencode", - start: async () => ({ - status: "accepted" as const, - sessionId, - metadata: { adapterKind: "opencode" } - }) - }); - - const mission = fixture.missionService.create({ - prompt: "Allow a planning worker to think quietly without premature startup warnings.", - laneId: fixture.laneId - }); - - const launch = await fixture.aiOrchestratorService.startMissionRun({ - missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "opencode" - }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; - - fixture.orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: "planning-worker", - title: "Planning worker", - stepIndex: 0, - dependencyStepKeys: [], - laneId: fixture.laneId, - executorKind: "manual", - metadata: { - instructions: "Research the codebase and report back with a plan.", - modelId: "anthropic/claude-sonnet-4-6", - phaseKey: "planning", - stepType: "planning", - readOnlyExecution: true, - }, - } - ] - }); - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((s) => s.stepKey === "planning-worker" && s.status === "ready"); - if (!readyStep) throw new Error("Expected planning-worker step to be ready"); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "opencode" - }); - - expect(attempt.status).toBe("running"); - expect(attempt.executorSessionId).toBe(sessionId); - - await vi.advanceTimersByTimeAsync(15_100); - - const runtimeEvents = fixture.orchestratorService - .listRuntimeEvents({ - attemptId: attempt.id, - limit: 10 - }) - .filter((entry) => String(entry.eventType) === "startup_verification_warning"); - expect(runtimeEvents).toHaveLength(0); - } finally { - fixture.dispose(); - vi.useRealTimers(); - } - }); - - it("projects coordinator-loss interventions as blocked run views instead of canceled runs", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Recover the coordinator if it disappears.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "worker-alpha", - title: "Worker alpha", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "manual", - }, - ], - }); - fixture.missionService.update({ - missionId: mission.id, - status: "planning", - }); - - fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "unrecoverable_error", - title: "Coordinator unavailable", - body: "Coordinator agent is not available for this run.", - requestedAction: "Resume after coordinator runtime is healthy.", - metadata: { reasonCode: "coordinator_unavailable" }, - pauseMission: true, - }); - fixture.orchestratorService.cancelRun({ runId: started.run.id, reason: "operator cleanup" }); - - const runView = await fixture.aiOrchestratorService.getRunView({ - missionId: mission.id, - runId: started.run.id, - }); - - expect(runView).not.toBeNull(); - expect(runView?.lifecycle.displayStatus).toBe("blocked"); - expect(runView?.haltReason?.source).toBe("intervention"); - expect(runView?.coordinator.available).toBe(false); - expect(runView?.haltReason?.title).toMatch(/coordinator unavailable/i); - } finally { - fixture.dispose(); - } - }); - - it("keeps canceled run views terminal when coordinator availability is stale offline", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Cancel after coordinator shutdown.", - laneId: fixture.laneId, - }); - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "worker-alpha", - title: "Worker alpha", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "manual", - }, - ], - }); - await updateMissionStateDocument({ - projectRoot: fixture.projectRoot, - missionId: mission.id, - runId: started.run.id, - goal: mission.prompt, - patch: { - coordinatorAvailability: { - available: false, - mode: "offline", - summary: "Coordinator unavailable", - detail: "I’ve stopped the run.", - updatedAt: "2026-02-20T00:02:00.000Z", - }, - }, - }); - fixture.orchestratorService.cancelRun({ runId: started.run.id, reason: "operator cleanup" }); - fixture.missionService.update({ - missionId: mission.id, - status: "canceled", - }); - - const runView = await fixture.aiOrchestratorService.getRunView({ - missionId: mission.id, - runId: started.run.id, - }); - - expect(runView?.lifecycle.displayStatus).toBe("canceled"); - expect(runView?.haltReason?.source).toBe("run"); - expect(runView?.haltReason?.title).toBe("Run canceled"); - expect(runView?.coordinator.available).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("prefers the active reopened run over the newest terminal run", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Resume the older run if it becomes active again.", - laneId: fixture.laneId, - }); - const firstRun = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - }); - fixture.orchestratorService.cancelRun({ runId: firstRun.run.id, reason: "paused for later" }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - - const secondRun = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - }); - fixture.orchestratorService.cancelRun({ runId: secondRun.run.id, reason: "superseded" }); - fixture.orchestratorService.addPostCompletionSteps({ - runId: firstRun.run.id, - steps: [ - { - stepKey: "resume-work", - title: "Resume work", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "manual", - }, - ], - }); - - const runView = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id }); - - expect(runView?.runId).toBe(firstRun.run.id); - expect(runView?.lifecycle.runStatus).toBe("active"); - } finally { - fixture.dispose(); - } - }); - - it("ignores open interventions from older runs when a newer run is healthy", async () => { - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Keep the current run healthy.", - laneId: fixture.laneId, - }); - const oldRun = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - }); - fixture.missionService.addIntervention({ - missionId: mission.id, - interventionType: "unrecoverable_error", - title: "Old run blocked", - body: "A previous run needs attention.", - requestedAction: "Ignore for current run.", - metadata: { runId: oldRun.run.id, reasonCode: "coordinator_unavailable" }, - pauseMission: false, - }); - fixture.orchestratorService.cancelRun({ runId: oldRun.run.id, reason: "old run closed" }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - - const currentRun = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [], - }); - fixture.missionService.update({ - missionId: mission.id, - status: "in_progress", - }); - - const runView = await fixture.aiOrchestratorService.getRunView({ - missionId: mission.id, - runId: currentRun.run.id, - }); - - expect(runView?.runId).toBe(currentRun.run.id); - expect(runView?.lifecycle.displayStatus).not.toBe("blocked"); - expect(runView?.coordinator.available).not.toBe(false); - expect(runView?.haltReason).toBeNull(); - } finally { - fixture.dispose(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts deleted file mode 100644 index 86e74d836..000000000 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ /dev/null @@ -1,11691 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import nodePath from "node:path"; -import type { - MissionDetail, - MissionInterventionResolutionKind, - MissionStepStatus, - MissionStatus, - CancelOrchestratorRunArgs, - OrchestratorExecutorKind, - OrchestratorRun, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorRuntimeEvent, - OrchestratorStepStatus, - OrchestratorWorkerState, - OrchestratorWorkerStatus, - OrchestratorRuntimeQuestionLink, - TerminalRuntimeState, - SteerMissionArgs, - SteerMissionResult, - CleanupOrchestratorTeamResourcesArgs, - CleanupOrchestratorTeamResourcesResult, - UserSteeringDirective, - OrchestratorChatMessage, - OrchestratorChatTarget, - SendOrchestratorChatArgs, - GetOrchestratorChatArgs, - GetGlobalChatArgs, - ListOrchestratorChatThreadsArgs, - GetOrchestratorThreadMessagesArgs, - SendOrchestratorThreadMessageArgs, - GetActiveAgentsArgs, - SendAgentMessageArgs, - OrchestratorWorkerDigest, - ListOrchestratorWorkerDigestsArgs, - GetOrchestratorWorkerDigestArgs, - GetOrchestratorContextCheckpointArgs, - ListOrchestratorLaneDecisionsArgs, - ListOrchestratorArtifactsArgs, - ListOrchestratorWorkerCheckpointsArgs, - MissionMetricsConfig, - MissionMetricSample, - GetMissionMetricsArgs, - SetMissionMetricsConfigArgs, - OrchestratorThreadEvent, - DagMutationEvent, - AgentChatEvent, - AgentChatEventEnvelope, - DelegationContract, - TeamManifest, - ExecutionPlanPreview, - ExecutionPlanPhase, - ExecutionPlanStepPreview, - PhaseCard, - OrchestratorWorkerRole, - RecoveryLoopPolicy, - AggregatedUsageStats, - GetAggregatedUsageArgs, - RecoveryLoopState, - IntegrationPrPolicy, - OrchestratorRunStatus, - OrchestratorTeamMember, - OrchestratorTeamRuntimeState, - MissionAgentRuntimeConfig, - FinalizeRunArgs, - FinalizeRunResult, - GetMissionStateDocumentArgs, - MissionStateDocument, - MissionStateDocumentPatch, - MissionCloseoutRequirement, - MissionCloseoutRequirementKey, - MissionCloseoutRequirementStatus, - MissionFinalizationPolicy, - MissionFinalizationState, - MissionCoordinatorAvailability, - MissionStatePendingIntervention, - MissionStateProgress, - MissionStateStepOutcome, - GetMissionLogsArgs, - GetMissionLogsResult, - GetMissionRunViewArgs, - MissionLogEntry, - MissionLogChannel, - ExportMissionLogsArgs, - ExportMissionLogsResult, - MissionRunView, - MissionRunViewDisplayStatus, - MissionRunViewHaltReason, - MissionRunViewLatestIntervention, - MissionRunViewProgressItem, - MissionRunViewSeverity, - MissionRunViewWorkerSummary, - OrchestratorArtifact, - OrchestratorWorkerCheckpoint, - GetOrchestratorPromptInspectorArgs, - GetPlanningPromptPreviewArgs, - OrchestratorPromptInspector, - ValidationEvidenceRequirement, -} from "../../../shared/types"; -import type { ModelConfig, OrchestratorCallType } from "../../../shared/types"; -import { - DEFAULT_RECOVERY_LOOP_POLICY, - DEFAULT_INTEGRATION_PR_POLICY, -} from "./orchestratorConstants"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { modelConfigToServiceModel, thinkingLevelToReasoningEffort } from "../../../shared/modelProfiles"; -import { isWorkerBootstrapNoiseLine } from "../../../shared/workerRuntimeNoise"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { createMissionService } from "../missions/missionService"; -import type { createOrchestratorService } from "./orchestratorService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createPrService } from "../prs/prService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import type { MissionBudgetService } from "./missionBudgetService"; -import { - buildComputerUseOwnerSnapshot, - getComputerUseArtifactKinds, -} from "../computerUse/controlPlane"; -import { - CoordinatorAgent, - type CoordinatorAvailableProvider, - type CoordinatorPlanningStartupFailure, - type CoordinatorProjectContext, - type CoordinatorRuntimeFailure, - type CoordinatorUserRules, -} from "./coordinatorAgent"; -import { routeEventToCoordinator } from "./runtimeEventRouter"; -import { - deleteCoordinatorCheckpoint, - getCoordinatorCheckpointPath, - getMissionStateDocumentPath, - readCoordinatorCheckpoint, - readMissionStateDocument, - updateMissionStateDocument, -} from "./missionStateDoc"; -import { getErrorMessage, normalizeBranchName } from "../shared/utils"; -import { - buildCoordinatorPromptInspector, - buildPlanningPromptPreview, - buildWorkerPromptInspector, -} from "./promptInspector"; -import { COORDINATOR_TOOL_NAMES } from "./coordinatorTools"; -import { normalizeCoordinatorToolName } from "./delegationContracts"; - -type CoordinatorLifecycleState = - | "booting" - | "analyzing_prompt" - | "fetching_project_context" - | "launching_planner" - | "waiting_on_planner" - | "running_phase" - | "planner_launch_failed" - | "stopped"; - -const WORKER_STARTUP_ATTACH_TIMEOUT_MS = 75_000; -const RUN_VIEW_CACHE_TTL_MS = 750; -const RUN_VIEW_STALE_WHILE_REFRESHING_MS = 5_000; - -// ── Module imports (extracted from this file) ──────────────────── -import type { - MissionRunStartArgs, - MissionRunStartResult, - OrchestratorHookCommandRunner, - MissionRuntimeProfile, - SessionRuntimeSignal, - AttemptRuntimeTracker, - OrchestratorContext, - OrchestratorChatSessionState, - WorkerDeliveryContext, - ParallelMissionStepDescriptor, - ResolvedCallTypeConfig, -} from "./orchestratorContext"; - -// Re-export all types -export type { OrchestratorContext } from "./orchestratorContext"; - -// Import all constants, helpers, and utility functions from orchestratorContext -import { - STEERING_DIRECTIVES_METADATA_KEY, - MAX_PERSISTED_STEERING_DIRECTIVES, - HEALTH_SWEEP_INTERVAL_MS, - HEALTH_SWEEP_ACTIVE_RUN_SCAN_LIMIT, - STALE_ATTEMPT_GRACE_MS, - WORKER_WAITING_INPUT_INTERVENTION_COOLDOWN_MS, - WORKER_EVENT_HEARTBEAT_INTERVAL_MS, - MAX_CHAT_LINE_CHARS, - MAX_LATEST_CHAT_MESSAGE_CHARS, - GRACEFUL_CANCEL_NOTIFY_TIMEOUT_MS, - GRACEFUL_CANCEL_INTERRUPT_TIMEOUT_MS, - GRACEFUL_CANCEL_DISPOSE_TIMEOUT_MS, - GRACEFUL_CANCEL_DRAIN_WAIT_MS, - GRACEFUL_CANCEL_DRAIN_POLL_MS, - MAX_STEERING_CONTEXT_DIRECTIVES, - MAX_STEERING_CONTEXT_CHARS, - MAX_STEERING_DIRECTIVES_PER_STEP, - MAX_RUNTIME_SIGNAL_PREVIEW_CHARS, - ACTIVE_ATTEMPT_STATUSES, - CALL_TYPE_DEFAULTS, - // Utility functions - nowIso, - runBestEffortWithTimeout, - isRecord, - asRecord, - asBool, - digestSignalText, - buildQuestionThreadLink, - buildQuestionReplyLink, - parseQuestionLink, - detectWaitingInputSignal, - clipTextForContext, - parseJsonRecord, - workerStateFromRuntimeSignal, - parseTerminalRuntimeState, - toOptionalString, - mapOrchestratorStepStatus, - deriveMissionStatusFromRun, - buildOutcomeSummary, - extractRunFailureMessage, - filterExecutionSteps, - runOrchestratorHookCommand, - getModelCapabilities, -} from "./orchestratorContext"; - -// Re-export public functions that external consumers depend on -export { getModelCapabilities } from "./orchestratorContext"; - -// Import from coordinator session module -import { - PM_SYSTEM_PREAMBLE, -} from "./coordinatorSession"; - -// Import from runtime event router module -import { - onAgentChatEvent as onAgentChatEventCtx, - onSessionRuntimeSignal as onSessionRuntimeSignalCtx, - pruneSessionRuntimeSignals as pruneSessionRuntimeSignalsCtx, -} from "./runtimeEventRouter"; - -// Import from mission lifecycle module -import { - inferRoleFromStepMetadata, - isParallelCandidateStepType, - stepTitleForMessage, - resolveAttemptOwnerId, - resolveAttemptOwnerIdFromRows, - TERMINAL_PHASE_STEP_STATUSES, - discoverProjectDocs as discoverProjectDocsCtx, - resolveActivePhaseSettings as resolveActivePhaseSettingsCtx, - resolveActiveRuntimeProfile as resolveActiveRuntimeProfileCtx, - transitionMissionStatus as transitionMissionStatusCtx, - getMaxCoordinatorRecoveries as getMaxCoordinatorRecoveriesCtx, - dispatchOrchestratorHookCtx, - maybeDispatchTeammateIdleHookCtx, -} from "./missionLifecycle"; -import { resolveFirstPostPlanningPhaseKey } from "../missions/phaseEngine"; -import type { HookDispatchDeps } from "./missionLifecycle"; -import { hasMaterialWorkerChatEvent } from "../../../shared/chatTranscript"; -import { - isProofEvidenceRequirement, - resolveCloseoutRequirementKeyFromArtifact, - resolveOrchestratorArtifactUri, -} from "../../../shared/proofArtifacts"; -import { getCapabilityForRequirement } from "../computerUse/localComputerUse"; - -// ── Intervention Prompt Builder ── - -function buildInterventionResolverPrompt(args: { - missionTitle: string; - missionPrompt: string; - interventionDescription: string; - runContext: string; - steeringContext: string; - confidenceThreshold: number; -}): string { - return [ - `You are an AI orchestrator deciding how to handle an intervention during a mission.`, - ``, - `Mission: ${args.missionTitle}`, - `Prompt: ${args.missionPrompt.slice(0, 300)}`, - ``, - `Intervention: ${args.interventionDescription}`, - ``, - `Run context: ${args.runContext}`, - args.steeringContext.length > 0 ? `\nSteering context:\n${args.steeringContext}` : "", - ``, - `Confidence threshold for auto-resolution: ${args.confidenceThreshold}`, - ``, - `Respond with a JSON object:`, - `{`, - ` "autoResolvable": boolean,`, - ` "confidence": number (0-1),`, - ` "suggestedAction": "retry" | "skip" | "add_workaround" | "escalate",`, - ` "reasoning": string,`, - ` "retryInstructions": string (if action is retry)`, - `}` - ].join("\n"); -} - -type MissionLaunchFailureStage = - | "run_created" - | "lane_create" - | "coordinator_start" - | "run_activate"; - -export function hasRecoverableWorkerRuntimeState(states: Iterable): boolean { - for (const state of states) { - if (state.state !== "completed" && state.state !== "failed" && state.state !== "disposed") { - return true; - } - } - return false; -} - -export function buildCoordinatorEvaluationActionHints(graph: OrchestratorRunGraph): string[] { - const hints: string[] = []; - const executionSteps = filterExecutionSteps(graph.steps); - if (executionSteps.length === 0) return hints; - const nextImplementationPhaseKey = resolveFirstPostPlanningPhaseKey( - deriveConfiguredPhasesForSync(graph) as unknown as PhaseCard[], - ); - - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : null; - const phaseRuntime = isRecord(runMeta?.phaseRuntime) ? runMeta.phaseRuntime : null; - const currentPhaseKey = typeof phaseRuntime?.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim().toLowerCase() : ""; - const currentPhaseName = typeof phaseRuntime?.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim().toLowerCase() : ""; - - const stepsInCurrentPhase = executionSteps.filter((step) => { - const stepMeta = isRecord(step.metadata) ? step.metadata : null; - const stepPhaseKey = typeof stepMeta?.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepPhaseName = typeof stepMeta?.phaseName === "string" ? stepMeta.phaseName.trim().toLowerCase() : ""; - if (currentPhaseKey.length > 0 && stepPhaseKey === currentPhaseKey) return true; - if (currentPhaseName.length > 0 && stepPhaseName === currentPhaseName) return true; - return false; - }); - const currentPhaseTerminal = stepsInCurrentPhase.filter((step) => TERMINAL_PHASE_STEP_STATUSES.has(step.status)); - const currentPhaseNonTerminal = stepsInCurrentPhase.filter((step) => !TERMINAL_PHASE_STEP_STATUSES.has(step.status)); - const remainingExecution = executionSteps.filter((step) => !TERMINAL_PHASE_STEP_STATUSES.has(step.status)); - const currentPhasePlanningSucceeded = - currentPhaseKey === "planning" || currentPhaseName === "planning" - ? stepsInCurrentPhase.some((step) => step.status === "succeeded") - : false; - - if ( - (currentPhaseKey === "planning" || currentPhaseName === "planning") - && currentPhasePlanningSucceeded - && currentPhaseTerminal.length > 0 - && currentPhaseNonTerminal.length === 0 - ) { - hints.push(`REQUIRED NEXT ACTION: Planning work is complete. Call set_current_phase with phaseKey "${nextImplementationPhaseKey}" before spawning any implementation workers.`); - } - - if (remainingExecution.length === 0) { - if (currentPhaseKey === "planning" || currentPhaseName === "planning") { - if (currentPhasePlanningSucceeded) { - hints.push("REQUIRED NEXT ACTION: The run has no executable work left but is still in planning. Advance to the next phase and spawn the implementation work, or call fail_mission if the mission cannot proceed."); - } else { - hints.push("REQUIRED NEXT ACTION: Planning has no active executable work and no successful planner result yet. Spawn a replacement planning worker or fail the mission if planning cannot proceed."); - } - } else { - hints.push("REQUIRED NEXT ACTION: All executable steps are terminal. If the mission goal is satisfied, call complete_mission with a concise summary. Otherwise create or spawn the missing follow-up work now."); - } - } - - return hints; -} - -type PhaseSyncTarget = { - phaseKey: string; - phaseName: string; - phaseModel: Record | null; - phaseInstructions: string | null; - phaseValidation: Record | null; - phaseBudget: Record | null; - sourceStepId: string | null; -}; - -type PhaseSyncCard = { - phaseKey: string; - name: string; - position: number; - model: Record | null; - instructions: string | null; - validationGate: Record | null; - budget: Record | null; -}; - -function sortStepsForPhaseSync(steps: OrchestratorStep[]): OrchestratorStep[] { - return [...steps].sort((a, b) => { - const aMeta = isRecord(a.metadata) ? a.metadata : {}; - const bMeta = isRecord(b.metadata) ? b.metadata : {}; - const aPhasePos = Number(aMeta.phasePosition ?? Number.MAX_SAFE_INTEGER); - const bPhasePos = Number(bMeta.phasePosition ?? Number.MAX_SAFE_INTEGER); - if (Number.isFinite(aPhasePos) && Number.isFinite(bPhasePos) && aPhasePos !== bPhasePos) { - return aPhasePos - bPhasePos; - } - if (a.stepIndex !== b.stepIndex) return a.stepIndex - b.stepIndex; - return a.title.localeCompare(b.title); - }); -} - -function deriveConfiguredPhasesForSync(graph: OrchestratorRunGraph): PhaseSyncCard[] { - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : {}; - const rawPhases = Array.isArray(runMeta.phaseOverride) ? runMeta.phaseOverride : []; - return rawPhases - .map((entry) => { - const phase = asRecord(entry); - if (!phase) return null; - const phaseKey = typeof phase.phaseKey === "string" ? phase.phaseKey.trim() : ""; - const name = typeof phase.name === "string" ? phase.name.trim() : ""; - if (!phaseKey.length || !name.length) return null; - const positionRaw = Number(phase.position ?? Number.MAX_SAFE_INTEGER); - return { - phaseKey, - name, - position: Number.isFinite(positionRaw) ? positionRaw : Number.MAX_SAFE_INTEGER, - model: asRecord(phase.model), - instructions: typeof phase.instructions === "string" && phase.instructions.trim().length > 0 - ? phase.instructions.trim() - : null, - validationGate: asRecord(phase.validationGate), - budget: asRecord(phase.budget), - } satisfies PhaseSyncCard; - }) - .filter((phase): phase is PhaseSyncCard => phase !== null) - .sort((a, b) => a.position - b.position); -} - -function stepMatchesPhaseForSync(step: OrchestratorStep, phase: PhaseSyncCard): boolean { - const meta = isRecord(step.metadata) ? step.metadata : {}; - const stepPhaseKey = typeof meta.phaseKey === "string" ? meta.phaseKey.trim() : ""; - const stepPhaseName = typeof meta.phaseName === "string" ? meta.phaseName.trim() : ""; - return stepPhaseKey === phase.phaseKey || stepPhaseName === phase.name; -} - -function phaseStepCountsAsCompleteForSync(step: OrchestratorStep, phase: PhaseSyncCard): boolean { - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - if (phaseKey === "planning" || phaseName === "planning") { - return step.status === "succeeded"; - } - return TERMINAL_PHASE_STEP_STATUSES.has(step.status); -} - -function buildPhaseSyncTargetFromCard(card: PhaseSyncCard, sourceStepId: string | null): PhaseSyncTarget { - return { - phaseKey: card.phaseKey, - phaseName: card.name, - phaseModel: card.model, - phaseInstructions: card.instructions, - phaseValidation: card.validationGate, - phaseBudget: card.budget, - sourceStepId, - }; -} - -function resolvePhaseCardsFromRunMetadata(runMetadata: Record): PhaseCard[] { - const coercePhaseCards = (value: unknown): PhaseCard[] => { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is PhaseCard => { - const phase = asRecord(entry); - if (!phase) return false; - return typeof phase.id === "string" - && typeof phase.phaseKey === "string" - && typeof phase.name === "string" - && typeof phase.description === "string" - && typeof phase.instructions === "string" - && Number.isFinite(Number(phase.position)) - && asRecord(phase.model) !== null - && asRecord(phase.orderingConstraints) !== null - && asRecord(phase.askQuestions) !== null - && asRecord(phase.validationGate) !== null; - }); - }; - const phaseConfiguration = asRecord(runMetadata.phaseConfiguration); - if (phaseConfiguration && Array.isArray(phaseConfiguration.selectedPhases)) { - return coercePhaseCards(phaseConfiguration.selectedPhases); - } - if (Array.isArray(runMetadata.phaseOverride)) { - return coercePhaseCards(runMetadata.phaseOverride); - } - const phaseOverride = asRecord(runMetadata.phaseOverride); - if (phaseOverride && Array.isArray(phaseOverride.selectedPhases)) { - return coercePhaseCards(phaseOverride.selectedPhases); - } - return []; -} - -export function deriveMissionPhaseSyncTarget(graph: OrchestratorRunGraph): PhaseSyncTarget | null { - const executionSteps = sortStepsForPhaseSync(filterExecutionSteps(graph.steps)); - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : {}; - const phaseRuntime = isRecord(runMeta.phaseRuntime) ? runMeta.phaseRuntime : {}; - const currentPhaseKey = typeof phaseRuntime.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - const currentPhaseName = typeof phaseRuntime.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - const configuredPhases = deriveConfiguredPhasesForSync(graph); - - if (configuredPhases.length > 0) { - const currentPhaseIndex = configuredPhases.findIndex((phase) => - phase.phaseKey === currentPhaseKey || phase.name === currentPhaseName, - ); - if (currentPhaseIndex >= 0) { - const currentPhase = configuredPhases[currentPhaseIndex]!; - const currentPhaseExecutionSteps = executionSteps.filter((step) => stepMatchesPhaseForSync(step, currentPhase)); - if (currentPhaseExecutionSteps.length === 0) { - // Hold on the currently entered configured phase until it produces work, - // instead of regressing back to the last completed phase. - return buildPhaseSyncTargetFromCard(currentPhase, null); - } - if ( - currentPhaseExecutionSteps.length > 0 - && currentPhaseExecutionSteps.every((step) => TERMINAL_PHASE_STEP_STATUSES.has(step.status)) - ) { - const currentPhaseCompleted = currentPhaseExecutionSteps.some((step) => - phaseStepCountsAsCompleteForSync(step, currentPhase) - ); - if (currentPhaseCompleted) { - const sourceStepId = currentPhaseExecutionSteps[currentPhaseExecutionSteps.length - 1]?.id ?? null; - return buildPhaseSyncTargetFromCard(currentPhase, sourceStepId); - } - } - } - } - - const activeExecutionStep = - executionSteps.find((step) => !TERMINAL_PHASE_STEP_STATUSES.has(step.status)) - ?? executionSteps[executionSteps.length - 1] - ?? null; - if (!activeExecutionStep) return null; - - const stepMeta = isRecord(activeExecutionStep.metadata) ? activeExecutionStep.metadata : {}; - return { - phaseKey: typeof stepMeta.phaseKey === "string" && stepMeta.phaseKey.trim().length > 0 - ? stepMeta.phaseKey.trim() - : "development", - phaseName: typeof stepMeta.phaseName === "string" && stepMeta.phaseName.trim().length > 0 - ? stepMeta.phaseName.trim() - : "Development", - phaseModel: asRecord(stepMeta.phaseModel), - phaseInstructions: - typeof stepMeta.phaseInstructions === "string" && stepMeta.phaseInstructions.trim().length > 0 - ? stepMeta.phaseInstructions.trim() - : null, - phaseValidation: asRecord(stepMeta.phaseValidation), - phaseBudget: asRecord(stepMeta.phaseBudget), - sourceStepId: activeExecutionStep.id, - }; -} - -export function normalizeCoordinatorUpdateForChat(message: string): string | null { - const compact = message - .replace(/\u001b\[[0-9;]*m/g, "") - .replace(/^\[Coordinator\]\s*/i, "") - .replace(/([.!?])([A-Z])/g, "$1 $2") - .replace(/\s+/g, " ") - .trim(); - if (!compact.length) return null; - - if ( - /^\{/.test(compact) - || /^tool\s+/i.test(compact) - || /^assistant:/i.test(compact) - || /^user:/i.test(compact) - ) { - return null; - } - - const lowerCompact = compact.toLowerCase(); - if (lowerCompact.includes("reviewing the mission, building the plan")) { - return "Planning is active. I’m briefing the planning worker now."; - } - if (lowerCompact.includes("moving through planning, implementation, and validation")) { - return "Mission started. I’m following the enabled phases and will hand work to workers as each phase opens."; - } - if (lowerCompact.includes("read the key files") || lowerCompact.includes("review the key files")) { - return "I’m reviewing the relevant files and mapping out the next step."; - } - if (lowerCompact.includes("look at the router") || lowerCompact.includes("look at the route")) { - return "I’m checking the routing setup so I can line up the next task cleanly."; - } - if (lowerCompact.includes("planning task") || lowerCompact.includes("planning tasks")) { - return "I’m turning the mission into concrete planning tasks now."; - } - if (lowerCompact.includes("spawn the planning worker")) { - return "I’ve prepared the planning task and I’m starting the worker now."; - } - if (lowerCompact.includes("spawn") && lowerCompact.includes("worker")) { - return "I’ve prepared the next task and I’m starting the worker now."; - } - if (lowerCompact.includes("transition") && lowerCompact.includes("phase")) { - return "I’m wrapping up this phase and moving the run to the next one."; - } - - const normalized = compact - .split(/(?<=[.!?])\s+/) - .map((entry) => entry.trim()) - .filter((entry) => { - const lowered = entry.toLowerCase(); - return entry.length > 0 - && !/^i have (?:all|enough|the)\b/.test(lowered) - && !/^i now have\b/.test(lowered) - && !/^the mission is clear\b/.test(lowered) - && !/^the implementation is clear\b/.test(lowered); - }) - .slice(0, 2) - .join(" ") - .replace(/\b(?:Now\s+)?I need to\b.*$/i, "") - .replace(/\b(?:Next,\s*)?I should\b.*$/i, "") - .replace(/\s+/g, " ") - .trim(); - if (!normalized.length) return null; - - const alphaChars = normalized.match(/[A-Za-z]/g)?.length ?? 0; - if (alphaChars < 12) return null; - return clipTextForContext(normalized, 220); -} - -// Quality gate module imports removed — quality evaluation is the coordinator's domain - -// Import from team runtime config module -import { - normalizeAgentRuntimeFlags, -} from "./teamRuntimeConfig"; - -// Import from team runtime state module -import { - getTeamMembersForRun as getTeamMembersForRunCtx, - updateTeamRuntimePhase as updateTeamRuntimePhaseCtx, - getTeamRuntimeStateForRun as getTeamRuntimeStateForRunCtx, -} from "./teamRuntimeState"; - -// Import from chat message module -import { - getMissionMetadata as getMissionMetadataCtx, - updateMissionMetadata as updateMissionMetadataCtx, - getMissionIdForRun as getMissionIdForRunCtx, - getRunMetadata as getRunMetadataCtx, - updateRunMetadata as updateRunMetadataCtx, - loadSteeringDirectivesFromMetadata as loadSteeringDirectivesCtx, - emitOrchestratorMessage as emitOrchestratorMessageCtx, - upsertThread as upsertThreadCtx, - appendChatMessageCtx, - updateChatMessage as updateChatMessageCtx, - listChatThreadsCtx, - getThreadMessagesCtx, - sendThreadMessageCtx, - sendChatCtx, - getChatCtx, - parseMentions, - deliverMessageToAgentCtx, - getGlobalChatCtx, - getActiveAgentsCtx, - sendAgentMessageWithMentionsCtx, - reconcileThreadedMessagingStateCtx, -} from "./chatMessageService"; -import type { ChatRoutingDeps, ReconciliationDeps } from "./chatMessageService"; - -// Import from worker tracking module -import { - getWorkerStates as getWorkerStatesCtx, - upsertWorkerState as upsertWorkerStateCtx, - listWorkerDigests as listWorkerDigestsCtx, - getWorkerDigest as getWorkerDigestCtx, - getContextCheckpoint as getContextCheckpointCtx, - listLaneDecisions as listLaneDecisionsCtx, - buildWorkerDigestFromAttempt as buildWorkerDigestFromAttemptCtx, - updateWorkerStateFromEventCtx, -} from "./workerTracking"; - -import { - setMissionMetricsConfig as setMissionMetricsConfigCtx, - getMissionMetrics as getMissionMetricsCtx, - getAggregatedUsage as getAggregatedUsageCtx, - propagateAttemptTokenUsage as propagateAttemptTokenUsageCtx, - createContextCheckpoint as createContextCheckpointCtx, -} from "./metricsAndUsage"; - -// Import from recovery service module -import { - ensureAttemptRuntimeTracker as ensureAttemptRuntimeTrackerCtx, - updateAttemptStagnationTracker as updateAttemptStagnationTrackerCtx, - getTrackedSessionState as getTrackedSessionStateCtx, - listRunningAttemptsForSession as listRunningAttemptsForSessionCtx, - hydratePersistedAttemptRuntimeState as hydratePersistedAttemptRuntimeStateCtx, - deletePersistedAttemptRuntimeState as deletePersistedAttemptRuntimeStateCtx, - persistAttemptRuntimeState as persistAttemptRuntimeStateCtx, -} from "./recoveryService"; - -// Import from model config resolver module -import { - resolveCallTypeConfig as resolveCallTypeConfigCtx, - resolveMissionDecisionTimeoutCapMs as resolveMissionDecisionTimeoutCapMsCtx, - resolveAiDecisionLikeTimeoutMs as resolveAiDecisionLikeTimeoutMsCtx, - resolveOrchestratorModelConfig as resolveOrchestratorModelConfigCtx, -} from "./modelConfigResolver"; - -// Import from worker delivery module -import { - resolveWorkerDeliveryContextCtx, - persistThreadWorkerLinksCtx, - sendWorkerMessageToSessionCtx, - sendWorkerMessageToSessionWithStatusCtx, - replayQueuedWorkerMessagesCtx, - routeMessageToCoordinatorCtx, - routeMessageToWorkerCtx, -} from "./workerDeliveryService"; -import type { WorkerDeliveryDeps } from "./workerDeliveryService"; - -export function deriveFallbackLaneStrategyDecision(args: { - descriptors: ParallelMissionStepDescriptor[]; - baseLaneId: string; -}): { - strategy: "single_lane" | "dependency_parallel" | "phase_parallel"; - maxParallelLanes: number; - rationale: string; - confidence: number; - stepAssignments: Array<{ - stepKey: string; - laneLabel: string; - rationale?: string; - }>; -} { - const ordered = [...args.descriptors].sort((a, b) => a.index - b.index || a.id.localeCompare(b.id)); - const assignmentByStepKey = new Map(); - const existingLaneLabelByLaneId = new Map(); - let existingLaneCounter = 0; - const assignLane = (stepKey: string, laneLabel: string, rationale: string): void => { - assignmentByStepKey.set(stepKey, { laneLabel, rationale }); - }; - - for (const descriptor of ordered) { - const existingLaneId = toOptionalString(descriptor.laneId); - if (!existingLaneId || existingLaneId === args.baseLaneId) continue; - let laneLabel = existingLaneLabelByLaneId.get(existingLaneId); - if (!laneLabel) { - existingLaneCounter += 1; - laneLabel = `existing-${existingLaneCounter}`; - existingLaneLabelByLaneId.set(existingLaneId, laneLabel); - } - assignLane(descriptor.stepKey, laneLabel, `Preserved existing mission lane '${existingLaneId}'.`); - } - - const independentRoots = ordered.filter((descriptor) => - descriptor.dependencyStepKeys.length === 0 - && isParallelCandidateStepType(descriptor.stepType) - && !assignmentByStepKey.has(descriptor.stepKey) - ); - if (independentRoots.length > 1) { - assignLane(independentRoots[0]!.stepKey, "base", "Primary root stream anchored on the base lane."); - independentRoots.slice(1).forEach((descriptor, index) => { - assignLane( - descriptor.stepKey, - `parallel-${index + 1}`, - "Independent root step assigned to a dedicated fallback parallel lane." - ); - }); - } else if (independentRoots.length === 1) { - assignLane(independentRoots[0]!.stepKey, "base", "Single root stream runs on the base lane."); - } - - for (const descriptor of ordered) { - if (assignmentByStepKey.has(descriptor.stepKey)) continue; - const dependencyLabels = [...new Set(descriptor.dependencyStepKeys - .map((depKey) => assignmentByStepKey.get(depKey)?.laneLabel ?? null) - .filter((label): label is string => typeof label === "string" && label.length > 0))]; - if (dependencyLabels.length === 1) { - assignLane(descriptor.stepKey, dependencyLabels[0]!, "Inherited lane from sole dependency chain."); - } else if (dependencyLabels.length > 1) { - assignLane(descriptor.stepKey, "base", "Multi-lane dependencies converge on the base lane."); - } else { - assignLane(descriptor.stepKey, "base", "No lane signal found; defaulting to base lane."); - } - } - - if (![...assignmentByStepKey.values()].some((assignment) => assignment.laneLabel === "base")) { - const anchor = ordered[0]; - if (anchor) { - assignLane(anchor.stepKey, "base", "Anchor fallback strategy with one base-lane step."); - } - } - - const fallbackLabels = [...new Set(ordered.map((descriptor) => assignmentByStepKey.get(descriptor.stepKey)?.laneLabel ?? "base"))]; - return { - strategy: fallbackLabels.length > 1 ? "dependency_parallel" : "single_lane", - maxParallelLanes: Math.max(1, fallbackLabels.length), - rationale: "Deterministic fallback lane strategy derived from dependencies and existing lane signals.", - confidence: 0.6, - stepAssignments: ordered.map((descriptor) => { - const assignment = assignmentByStepKey.get(descriptor.stepKey); - return { - stepKey: descriptor.stepKey, - laneLabel: assignment?.laneLabel ?? "base", - rationale: assignment?.rationale ?? "No lane signal found; defaulting to base lane." - }; - }) - }; -} - - -export function createAiOrchestratorService(args: { - db: AdeDb; - logger: Logger; - missionService: ReturnType; - orchestratorService: ReturnType; - agentChatService?: ReturnType | null; - laneService?: ReturnType | null; - projectConfigService?: ReturnType | null; - aiIntegrationService?: ReturnType | null; - prService?: ReturnType | null; - conflictService?: ReturnType | null; - queueLandingService?: ReturnType | null; - missionBudgetService?: MissionBudgetService | null; - computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; - projectRoot?: string; - onThreadEvent?: (event: OrchestratorThreadEvent) => void; - onDagMutation?: (event: DagMutationEvent) => void; - hookCommandRunner?: OrchestratorHookCommandRunner; -}) { - const { - db, - logger, - missionService, - orchestratorService, - agentChatService: initialAgentChatService, - laneService, - projectConfigService, - aiIntegrationService, - prService, - missionBudgetService, - computerUseArtifactBrokerService, - projectRoot, - onThreadEvent, - onDagMutation, - hookCommandRunner = runOrchestratorHookCommand - } = args; - let agentChatService = initialAgentChatService ?? null; - const syncLocks = new Set(); - const syncInFlight = new Map>(); - const workerStates = new Map(); - const activeSteeringDirectives = new Map(); - const runRuntimeProfiles = new Map(); - const chatMessages = new Map(); - const activeChatSessions = new Map(); - const chatTurnQueues = new Map>(); - const activeHealthSweepRuns = new Set(); - const sessionRuntimeSignals = new Map(); - const attemptRuntimeTrackers = new Map(); - const sessionSignalQueues = new Map>(); - const workerDeliveryThreadQueues = new Map>(); - const workerDeliveryInterventionCooldowns = new Map(); - const runTeamManifests = new Map(); - const runRecoveryLoopStates = new Map(); - const runViewCache = new Map | null; - }>(); - const cancelingRuns = new Set(); - const pendingCoordinatorEvals = new Map(); - const milestoneReadyNotificationSignatures = new Map(); - const runWatchdogTimers = new Map>(); - const subagentCompletionRollupSent = new Set(); - const validationSystemSignalDedupe = new Set(); - const workerProgressChatState = new Map(); - - // ── V2 Coordinator Agents (tool-based, replaces specialist calls) ── - const coordinatorAgents = new Map(); - const coordinatorRecoveryAttempts = new Map(); - const coordinatorStartInFlight = new Set(); - const coordinatorStartFailureCooldowns = new Map(); - const coordinatorChatSessions = new Map(); - const coordinatorChatSessionByRunId = new Map(); - const coordinatorChatRuntimeOwnedSessions = new Set(); - const coordinatorManualChatReplayBuffers = new Map>(); - const coordinatorLifecycleStates = new Map(); - const coordinatorWriteBarrierRuns = new Set(); - - // Team runtime state tracking - const teamRuntimeStates = new Map(); - - // Call type config cache - const callTypeConfigCache = new Map(); - const STARTUP_RUNTIME_EVENT_HYDRATION_LIMIT = 750; - const STARTUP_OPEN_QUESTION_REPLAY_LIMIT = 750; - - // Scalar mutable state wrapped for ctx - const disposedRef = { current: false }; - const healthSweepTimerRef = { current: null as NodeJS.Timeout | null }; - // Local scalar mirrors for hot-path checks. - let disposed = false; - let healthSweepTimer: NodeJS.Timeout | null = null; - - // ── OrchestratorContext — shared state for extracted modules ── - const ctx: OrchestratorContext = { - db, - logger, - missionService, - orchestratorService, - agentChatService, - laneService: laneService ?? null, - projectConfigService: projectConfigService ?? null, - aiIntegrationService: aiIntegrationService ?? null, - prService: prService ?? null, - missionBudgetService: missionBudgetService ?? null, - computerUseArtifactBrokerService: computerUseArtifactBrokerService ?? null, - projectRoot, - onThreadEvent, - onDagMutation, - hookCommandRunner, - syncLocks, - workerStates, - activeSteeringDirectives, - runRuntimeProfiles, - chatMessages, - activeChatSessions, - chatTurnQueues, - activeHealthSweepRuns, - sessionRuntimeSignals, - attemptRuntimeTrackers, - sessionSignalQueues, - workerDeliveryThreadQueues, - workerDeliveryInterventionCooldowns, - runTeamManifests, - runRecoveryLoopStates, - cancelingRuns, - aiTimeoutBudgetStepLocks: new Set(), - aiTimeoutBudgetRunLocks: new Set(), - aiRetryDecisionLocks: new Set(), - coordinatorSessions: new Map(), - coordinatorThinkingLoops: new Map(), - pendingCoordinatorEvals, - coordinatorAgents, - coordinatorRecoveryAttempts, - teamRuntimeStates, - callTypeConfigCache, - disposed: disposedRef, - healthSweepTimer: healthSweepTimerRef, - }; - - /** Purge per-run Map entries when a run reaches terminal status. */ - const purgeRunMaps = (runId: string): void => { - runTeamManifests.delete(runId); - runRecoveryLoopStates.delete(runId); - teamRuntimeStates.delete(runId); - coordinatorLifecycleStates.delete(runId); - for (const key of milestoneReadyNotificationSignatures.keys()) { - if (key.startsWith(`${runId}::`)) { - milestoneReadyNotificationSignatures.delete(key); - } - } - const evalTimer = pendingCoordinatorEvals.get(runId); - if (evalTimer) { - clearTimeout(evalTimer); - pendingCoordinatorEvals.delete(runId); - } - const watchdogTimers = runWatchdogTimers.get(runId); - if (watchdogTimers) { - for (const timer of watchdogTimers) clearTimeout(timer); - runWatchdogTimers.delete(runId); - } - for (const key of subagentCompletionRollupSent) { - if (key.startsWith(`${runId}:`)) { - subagentCompletionRollupSent.delete(key); - } - } - }; - - const hasRecoverableRuntimeWork = (): boolean => { - try { - const activeRun = db.get<{ found: number }>( - ` - select 1 as found - from orchestrator_runs - where status in ('active', 'bootstrapping', 'queued', 'paused') - limit 1 - ` - ); - if (activeRun?.found === 1) return true; - const persistedRuntime = db.get<{ found: number }>( - ` - select 1 as found - from orchestrator_attempt_runtime - limit 1 - ` - ); - return persistedRuntime?.found === 1; - } catch { - // Fail open so recovery still happens if the quick preflight query itself fails. - return true; - } - }; - - // Delegated to missionLifecycle.ts - const getMaxCoordinatorRecoveries = (missionId?: string | null) => getMaxCoordinatorRecoveriesCtx(ctx, missionId); - - // ── Orchestrator Call Type Resolution (delegated to modelConfigResolver) ── - const resolveCallTypeConfig = (missionId: string, callType: OrchestratorCallType) => - resolveCallTypeConfigCtx(ctx, missionId, callType); - - const getMissionMetadata = (missionId: string): Record => getMissionMetadataCtx(ctx, missionId); - const updateMissionMetadata = (missionId: string, mutate: (metadata: Record) => void): void => updateMissionMetadataCtx(ctx, missionId, mutate); - const getMissionIdForRun = (runId: string): string | null => getMissionIdForRunCtx(ctx, runId); - const getRunMetadata = (runId: string): Record => getRunMetadataCtx(ctx, runId); - const updateRunMetadata = (runId: string, mutate: (metadata: Record) => void): boolean => updateRunMetadataCtx(ctx, runId, mutate); - - // ── Thread management (delegated to chatMessageService) ───────── - const upsertThread = (args: Parameters[1]) => upsertThreadCtx(ctx, args); - - const loadSteeringDirectivesFromMetadata = (missionId: string) => loadSteeringDirectivesCtx(ctx, missionId); - - const appendChatMessage = (message: OrchestratorChatMessage): OrchestratorChatMessage => - appendChatMessageCtx(ctx, message); - const updateChatMessage = ( - messageId: string, - updater: Parameters[2], - ): OrchestratorChatMessage | null => updateChatMessageCtx(ctx, messageId, updater); - - const WORKER_PROGRESS_CHAT_MIN_INTERVAL_MS = 12_000; - const WORKER_PROGRESS_CHAT_REPEAT_INTERVAL_MS = 45_000; - const structuredThreadMessageIds = new Map(); - const structuredThreadUpsertSignatures = new Map(); - const structuredChatSessions = new Set(); - const TERMINAL_COORDINATOR_RUN_STATUSES = new Set(["succeeded", "failed", "canceled"]); - - const getRunStatusDirect = (runId: string): OrchestratorRunStatus | null => { - try { - const row = db.get<{ status: string | null }>( - `select status from orchestrator_runs where id = ? limit 1`, - [runId], - ); - const status = typeof row?.status === "string" ? row.status.trim() : ""; - return status.length > 0 ? status as OrchestratorRunStatus : null; - } catch { - return null; - } - }; - - const shouldSuppressCoordinatorWrites = (runId: string): boolean => { - if (coordinatorWriteBarrierRuns.has(runId)) return true; - const status = getRunStatusDirect(runId); - return status != null && TERMINAL_COORDINATOR_RUN_STATUSES.has(status); - }; - - const closeCoordinatorThread = (missionId: string, runId: string): void => { - upsertThread({ - missionId, - threadId: `mission:${missionId}`, - threadType: "coordinator", - title: "Orchestrator", - target: { - kind: "coordinator", - runId, - }, - status: "closed", - }); - }; - - const hasActivePersistedCoordinatorChatSessionForRun = (runId: string): boolean => { - const mappedSessionId = coordinatorChatSessionByRunId.get(runId); - if (mappedSessionId && coordinatorChatSessions.has(mappedSessionId)) return true; - - try { - const row = db.get<{ session_id: string | null }>( - ` - select session_id - from orchestrator_chat_threads - where thread_type = 'coordinator' - and run_id = ? - and status != 'closed' - and session_id is not null - and trim(session_id) != '' - order by updated_at desc - limit 1 - `, - [runId] - ); - return typeof row?.session_id === "string" && row.session_id.trim().length > 0; - } catch { - return false; - } - }; - - const updateCoordinatorMissionState = (args: { - missionId: string; - runId: string; - state: CoordinatorLifecycleState; - message: string; - delegation?: DelegationContract | null; - }): void => { - const detailByState: Record = { - booting: { available: true, mode: "continuation_required" }, - analyzing_prompt: { available: true, mode: "continuation_required" }, - fetching_project_context: { available: true, mode: "continuation_required" }, - launching_planner: { available: true, mode: "continuation_required" }, - waiting_on_planner: { available: true, mode: "consult_only" }, - running_phase: { available: true, mode: "continuation_required" }, - planner_launch_failed: { available: true, mode: "consult_only" }, - stopped: { available: false, mode: "offline" }, - }; - updateMissionStateDoc(args.runId, { - coordinatorAvailability: { - available: detailByState[args.state].available, - mode: detailByState[args.state].mode, - summary: args.message, - detail: null, - delegation: args.delegation - ? { - contractId: args.delegation.contractId, - workerIntent: args.delegation.workerIntent, - mode: args.delegation.mode, - status: args.delegation.status, - scopeKey: args.delegation.scope.key, - scopeLabel: args.delegation.scope.label ?? null, - activeWorkerIds: args.delegation.activeWorkerIds, - updatedAt: args.delegation.updatedAt, - } - : null, - updatedAt: nowIso(), - }, - pendingInterventions: pendingInterventionsForMission(args.missionId), - }); - }; - - const emitCoordinatorLifecycle = (args: { - missionId: string; - runId: string; - state: CoordinatorLifecycleState; - message: string; - force?: boolean; - delegation?: DelegationContract | null; - }): void => { - if (!args.force && shouldSuppressCoordinatorWrites(args.runId)) return; - const previous = coordinatorLifecycleStates.get(args.runId); - const delegationSignature = args.delegation - ? `${args.delegation.contractId}:${args.delegation.status}:${args.delegation.updatedAt}` - : null; - if ( - !args.force - && previous?.state === args.state - && previous.message === args.message - && (previous.delegationSignature ?? null) === delegationSignature - ) { - return; - } - coordinatorLifecycleStates.set(args.runId, { - state: args.state, - message: args.message, - delegationSignature, - }); - updateRunMetadata(args.runId, (metadata) => { - metadata.coordinator = { - ...(isRecord(metadata.coordinator) ? metadata.coordinator : {}), - lifecycleState: args.state, - lifecycleMessage: args.message, - delegation: args.delegation ?? null, - lifecycleUpdatedAt: nowIso(), - }; - }); - persistStructuredCoordinatorChatEvent({ - missionId: args.missionId, - runId: args.runId, - event: { - type: "status", - turnStatus: args.state === "planner_launch_failed" ? "failed" : args.state === "stopped" ? "interrupted" : "started", - message: args.message, - }, - }); - recordRuntimeEvent({ - runId: args.runId, - eventType: "progress", - eventKey: `coordinator_lifecycle:${args.state}`, - payload: { - audience: "mission_feed", - source: "coordinator_lifecycle", - state: args.state, - message: args.message, - delegation: args.delegation ?? null, - }, - }); - orchestratorService.appendTimelineEvent({ - runId: args.runId, - eventType: "coordinator_status", - reason: args.state, - detail: { - state: args.state, - message: args.message, - delegation: args.delegation ?? null, - }, - }); - updateCoordinatorMissionState(args); - }; - - const emitWorkerThreadMessage = (args: { - missionId: string; - runId: string; - step: OrchestratorStep; - attemptId: string | null; - sessionId: string | null; - laneId: string | null; - content: string; - metadata?: Record | null; - }): OrchestratorChatMessage => { - const threadId = - typeof args.attemptId === "string" && args.attemptId.trim().length > 0 - ? `worker:${args.missionId}:${args.attemptId.trim()}` - : null; - if (threadId) { - upsertThread({ - missionId: args.missionId, - threadId, - threadType: "worker", - title: `Worker: ${args.step.stepKey}`, - target: { - kind: "worker", - runId: args.runId, - stepId: args.step.id, - stepKey: args.step.stepKey, - attemptId: args.attemptId, - sessionId: args.sessionId, - laneId: args.laneId ?? args.step.laneId ?? null, - }, - status: "active", - }); - } - return appendChatMessage({ - id: randomUUID(), - missionId: args.missionId, - threadId, - role: "worker", - content: args.content, - timestamp: nowIso(), - stepKey: args.step.stepKey, - target: { - kind: "worker", - runId: args.runId, - stepId: args.step.id, - stepKey: args.step.stepKey, - attemptId: args.attemptId, - sessionId: args.sessionId, - laneId: args.laneId ?? args.step.laneId ?? null, - }, - visibility: "full", - deliveryState: "delivered", - sourceSessionId: args.sessionId, - attemptId: args.attemptId, - laneId: args.laneId ?? args.step.laneId ?? null, - runId: args.runId, - metadata: args.metadata ?? null, - }); - }; - - const clipStructuredText = (value: string, maxChars = 8_000): string => - value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 16))}\n[truncated]`; - - const structuredThreadKeyForEvent = (scopeKey: string, event: AgentChatEvent): string | null => { - switch (event.type) { - case "text": - case "reasoning": - return `${scopeKey}:${event.type}:${event.turnId ?? "turn"}`; - case "tool_call": - case "tool_result": - return `${scopeKey}:tool:${event.turnId ?? "turn"}:${event.itemId}`; - case "command": - return `${scopeKey}:command:${event.turnId ?? "turn"}:${event.itemId}`; - case "file_change": - return `${scopeKey}:file:${event.turnId ?? "turn"}:${event.itemId}:${event.path}`; - case "plan": - return `${scopeKey}:plan:${event.turnId ?? "turn"}`; - case "approval_request": - return `${scopeKey}:approval:${event.turnId ?? "turn"}:${event.itemId}`; - case "activity": - return `${scopeKey}:activity:${event.turnId ?? "turn"}:${event.activity}:${event.detail ?? ""}`; - case "delegation_state": - return `${scopeKey}:delegation:${event.contract.contractId}:${event.contract.status}:${event.contract.updatedAt}`; - default: - return null; - } - }; - - const buildStructuredEventSummary = (event: AgentChatEvent): string => { - switch (event.type) { - case "text": - return event.text; - case "reasoning": - return event.text; - case "tool_call": - return `Tool call: ${event.tool}`; - case "tool_result": - return `Tool result: ${event.tool}`; - case "approval_request": - return event.description; - case "status": - return event.message?.trim().length ? event.message.trim() : `Turn ${event.turnStatus}.`; - case "delegation_state": - return event.message?.trim().length - ? event.message.trim() - : `Delegation ${event.contract.workerIntent} is ${event.contract.status}.`; - case "done": - return `Turn ${event.status}.`; - case "error": - return event.message; - case "activity": - return event.detail?.trim().length ? event.detail.trim() : `Activity: ${event.activity}`; - case "plan": - return event.explanation?.trim().length - ? event.explanation.trim() - : event.steps.map((step) => `${step.status === "completed" ? "[x]" : step.status === "in_progress" ? "[>]" : "[ ]"} ${step.text}`).join("\n"); - case "command": - return `Command: ${event.command}`; - case "file_change": - return `${event.kind === "create" ? "Created" : event.kind === "delete" ? "Deleted" : "Updated"} ${nodePath.basename(event.path)}`; - case "step_boundary": - return `Step ${event.stepNumber}`; - case "user_message": - return event.text; - default: - return "Agent event"; - } - }; - - const hasTerminalStreamStatus = (status: unknown): boolean => - status === "completed" || status === "failed" || status === "interrupted"; - const isMissionControlToolEvent = (event: AgentChatEvent): boolean => { - if (event.type !== "tool_call" && event.type !== "tool_result") return false; - return (COORDINATOR_TOOL_NAMES as readonly string[]).includes(normalizeCoordinatorToolName(event.tool)); - }; - const RECOVERED_COORDINATOR_TOOL_REPLAY_MESSAGE = - "The coordinator wrote mission-control calls as text, so ADE is replaying those calls through the real mission-control tools."; - const isCoordinatorPseudoToolText = (text: string): boolean => { - const trimmed = text.trim(); - if (!trimmed.startsWith("```")) return false; - const labels = [...trimmed.matchAll(/^\s*\/\/\s*([A-Za-z_][\w-]*)\s*$/gm)] - .map((match) => normalizeCoordinatorToolName(match[1] ?? "")); - return labels.length > 0 - && labels.every((toolName) => (COORDINATOR_TOOL_NAMES as readonly string[]).includes(toolName)); - }; - const isCoordinatorPseudoToolFragment = (text: string): boolean => { - const trimmed = text.trim(); - if (!trimmed.length) return false; - if (isCoordinatorPseudoToolText(trimmed)) return true; - if (/^```(?:json|jsonc)?$/i.test(trimmed) || trimmed === "```") return true; - const labelMatch = trimmed.match(/^\/\/\s*([A-Za-z_][\w-]*)\s*$/); - if (labelMatch) { - const toolName = normalizeCoordinatorToolName(labelMatch[1] ?? ""); - return (COORDINATOR_TOOL_NAMES as readonly string[]).includes(toolName); - } - return /^\{/.test(trimmed) - || /^\}/.test(trimmed) - || /^,?\s*"[^"]+"\s*:/.test(trimmed) - || /^,\s*\{/.test(trimmed); - }; - - const shouldPersistStructuredChatEvent = (args: { - role: "worker" | "orchestrator"; - event: AgentChatEvent; - }): boolean => { - const { role, event } = args; - switch (event.type) { - case "text": - case "reasoning": - if (role === "orchestrator") return false; - if (event.type === "text") return !isCoordinatorPseudoToolFragment(event.text); - return true; - case "error": - case "approval_request": - case "delegation_state": - case "plan": - return true; - case "tool_call": - return role === "orchestrator" && isMissionControlToolEvent(event); - case "tool_result": - if (role === "orchestrator" && !isMissionControlToolEvent(event)) return false; - return hasTerminalStreamStatus(event.status ?? "completed"); - case "command": - case "file_change": - return hasTerminalStreamStatus(event.status); - case "status": - if ( - role === "orchestrator" - && event.turnStatus === "interrupted" - && event.message === RECOVERED_COORDINATOR_TOOL_REPLAY_MESSAGE - ) { - return false; - } - if (event.turnStatus === "failed" || event.turnStatus === "interrupted") return true; - // Coordinator lifecycle messages arrive as turnStatus "started" with a meaningful body. - return role === "orchestrator" && (event.message?.trim().length ?? 0) > 0; - case "done": - return event.status === "failed" || event.status === "interrupted"; - default: - return false; - } - }; - - const upsertStructuredThread = (args: Parameters[1]): void => { - const target = args.target && typeof args.target === "object" ? args.target : null; - const targetRecord = target && !Array.isArray(target) ? target as Record : {}; - const signature = JSON.stringify({ - threadType: args.threadType, - title: args.title, - status: args.status ?? null, - runId: typeof targetRecord.runId === "string" ? targetRecord.runId : null, - stepId: typeof targetRecord.stepId === "string" ? targetRecord.stepId : null, - stepKey: typeof targetRecord.stepKey === "string" ? targetRecord.stepKey : null, - attemptId: typeof targetRecord.attemptId === "string" ? targetRecord.attemptId : null, - sessionId: typeof targetRecord.sessionId === "string" ? targetRecord.sessionId : null, - laneId: typeof targetRecord.laneId === "string" ? targetRecord.laneId : null, - }); - if (structuredThreadUpsertSignatures.get(args.threadId) === signature) return; - structuredThreadUpsertSignatures.set(args.threadId, signature); - upsertThread(args); - }; - - const buildStructuredEventMetadata = (sessionId: string, event: AgentChatEvent): Record => { - if (event.type === "tool_call") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "tool", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId, - parentItemId: event.parentItemId ?? null, - tool: event.tool, - args: event.args, - status: "running", - }, - }; - } - if (event.type === "tool_result") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "tool", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId, - parentItemId: event.parentItemId ?? null, - tool: event.tool, - result: event.result, - status: event.status ?? "completed", - }, - }; - } - if (event.type === "error") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "error", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId ?? null, - message: event.message, - errorInfo: event.errorInfo ?? null, - }, - }; - } - if (event.type === "status") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "status", - sessionId, - turnId: event.turnId ?? null, - status: event.turnStatus, - message: event.message ?? null, - }, - }; - } - if (event.type === "delegation_state") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "delegation_state", - sessionId, - turnId: event.turnId ?? null, - contract: event.contract, - message: event.message ?? null, - }, - }; - } - if (event.type === "done") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "done", - sessionId, - turnId: event.turnId, - status: event.status, - model: event.model ?? null, - modelId: event.modelId ?? null, - usage: event.usage ?? null, - }, - }; - } - if (event.type === "reasoning") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "reasoning", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId ?? null, - summaryIndex: event.summaryIndex ?? null, - }, - }; - } - if (event.type === "text") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "text", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId ?? null, - }, - }; - } - if (event.type === "approval_request") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "approval_request", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId, - requestKind: event.kind, - description: event.description, - detail: event.detail ?? null, - }, - }; - } - if (event.type === "command") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "command", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId, - command: event.command, - cwd: event.cwd, - output: event.output, - exitCode: event.exitCode ?? null, - durationMs: event.durationMs ?? null, - status: event.status, - }, - }; - } - if (event.type === "file_change") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "file_change", - sessionId, - turnId: event.turnId ?? null, - itemId: event.itemId, - path: event.path, - diff: event.diff, - changeKind: event.kind, - status: event.status ?? null, - }, - }; - } - if (event.type === "plan") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "plan", - sessionId, - turnId: event.turnId ?? null, - explanation: event.explanation ?? null, - steps: event.steps, - }, - }; - } - if (event.type === "activity") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "activity", - sessionId, - turnId: event.turnId ?? null, - activity: event.activity, - detail: event.detail ?? null, - }, - }; - } - if (event.type === "step_boundary") { - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "step_boundary", - sessionId, - turnId: event.turnId ?? null, - stepNumber: event.stepNumber, - }, - }; - } - if (event.type === "user_message") { - const displayText = event.displayText?.trim(); - const internalKind = displayText === "Start mission coordination." || displayText === "Continue mission coordination." - ? "coordinator_runtime_prompt" - : null; - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "user_message", - sessionId, - turnId: event.turnId ?? null, - text: event.text, - ...(displayText ? { displayText } : {}), - ...(internalKind ? { internalKind } : {}), - attachments: event.attachments ?? [], - }, - }; - } - return { - source: "agent_chat_event", - missionChatMode: "thread_only", - structuredStream: { - kind: "unknown", - sessionId, - }, - }; - }; - - const mergeStructuredEventMetadata = ( - current: Record | null | undefined, - sessionId: string, - event: AgentChatEvent, - ): Record => { - const base = isRecord(current) ? { ...current } : {}; - const next = buildStructuredEventMetadata(sessionId, event); - const currentStructured = isRecord(base.structuredStream) ? base.structuredStream : null; - const nextStructured = isRecord(next.structuredStream) ? next.structuredStream : null; - if (event.type === "tool_result" && currentStructured) { - base.source = "agent_chat_event"; - base.missionChatMode = "thread_only"; - base.structuredStream = { - ...currentStructured, - ...(nextStructured ?? {}), - tool: typeof currentStructured.tool === "string" ? currentStructured.tool : event.tool, - result: event.result, - status: event.status ?? "completed", - }; - return base; - } - if ((event.type === "command" || event.type === "file_change" || event.type === "plan" || event.type === "activity") && currentStructured) { - base.source = "agent_chat_event"; - base.missionChatMode = "thread_only"; - base.structuredStream = { - ...currentStructured, - ...(nextStructured ?? {}), - }; - return base; - } - return { - ...base, - ...next, - }; - }; - - const appendOrUpdateStructuredThreadMessage = (args: { - missionId: string; - threadId: string; - role: "worker" | "orchestrator"; - senderStepKey?: string | null; - target: OrchestratorChatTarget; - timestamp: string; - sessionId: string; - runId: string | null; - attemptId: string | null; - laneId: string | null; - event: AgentChatEvent; - }): void => { - const key = structuredThreadKeyForEvent(args.threadId, args.event); - const summary = buildStructuredEventSummary(args.event); - const metadata = buildStructuredEventMetadata(args.sessionId, args.event); - const canAppendText = - args.event.type === "user_message" - || args.event.type === "activity" - || args.event.type === "text" - || args.event.type === "reasoning"; - if (key) { - const existingMessageId = structuredThreadMessageIds.get(key); - if (existingMessageId) { - updateChatMessage(existingMessageId, (current) => { - const nextContent = canAppendText - ? clipStructuredText(`${current.content}${summary}`) - : current.content; - return { - ...current, - content: nextContent, - timestamp: args.timestamp, - metadata: mergeStructuredEventMetadata(current.metadata, args.sessionId, args.event), - }; - }); - return; - } - } - - const created = appendChatMessage({ - id: randomUUID(), - missionId: args.missionId, - threadId: args.threadId, - role: args.role, - content: clipStructuredText(summary), - timestamp: args.timestamp, - stepKey: args.senderStepKey ?? null, - target: args.target, - visibility: "full", - deliveryState: "delivered", - sourceSessionId: args.sessionId, - attemptId: args.attemptId, - laneId: args.laneId, - runId: args.runId, - metadata, - }); - if (key) { - structuredThreadMessageIds.set(key, created.id); - } - }; - - const persistStructuredWorkerChatEvent = (envelope: AgentChatEventEnvelope): void => { - const workerState = [...workerStates.values()].find((candidate) => candidate.sessionId === envelope.sessionId); - if (!workerState) return; - const shouldPersist = shouldPersistStructuredChatEvent({ role: "worker", event: envelope.event }); - let graph: OrchestratorRunGraph; - try { - graph = orchestratorService.getRunGraph({ runId: workerState.runId, timelineLimit: 0 }); - } catch { - return; - } - const step = graph.steps.find((candidate) => candidate.id === workerState.stepId); - if (!step) return; - const missionId = graph.run.missionId ?? getMissionIdForRun(workerState.runId); - if (!missionId) return; - const threadId = `worker:${missionId}:${workerState.attemptId}`; - if (shouldPersist) { - const target: OrchestratorChatTarget = { - kind: "worker", - runId: workerState.runId, - stepId: step.id, - stepKey: step.stepKey, - attemptId: workerState.attemptId, - sessionId: workerState.sessionId, - laneId: step.laneId ?? null, - }; - upsertStructuredThread({ - missionId, - threadId, - threadType: "worker", - title: `Worker: ${step.stepKey}`, - target, - status: step.status === "running" ? "active" : "closed", - }); - structuredChatSessions.add(envelope.sessionId); - appendOrUpdateStructuredThreadMessage({ - missionId, - threadId, - role: "worker", - senderStepKey: step.stepKey, - target, - timestamp: envelope.timestamp, - sessionId: envelope.sessionId, - runId: workerState.runId, - attemptId: workerState.attemptId, - laneId: step.laneId ?? null, - event: envelope.event, - }); - } - if (!hasMaterialWorkerChatEvent([envelope])) return; - if (workerState.state !== "initializing" && workerState.state !== "spawned") return; - upsertWorkerState(workerState.attemptId, { - stepId: workerState.stepId, - runId: workerState.runId, - sessionId: workerState.sessionId, - executorKind: workerState.executorKind, - state: "working", - }); - }; - - const persistStructuredCoordinatorChatEvent = (args: { - missionId: string; - runId: string; - event: AgentChatEvent; - timestamp?: string; - sessionId?: string; - laneId?: string | null; - }): void => { - if (shouldSuppressCoordinatorWrites(args.runId)) return; - if (!shouldPersistStructuredChatEvent({ role: "orchestrator", event: args.event })) return; - const threadId = `mission:${args.missionId}`; - const target: OrchestratorChatTarget = { - kind: "coordinator", - runId: args.runId, - sessionId: args.sessionId ?? null, - laneId: args.laneId ?? null, - }; - upsertStructuredThread({ - missionId: args.missionId, - threadId, - threadType: "coordinator", - title: "Orchestrator", - target, - status: "active", - }); - appendOrUpdateStructuredThreadMessage({ - missionId: args.missionId, - threadId, - role: "orchestrator", - senderStepKey: null, - target, - timestamp: args.timestamp ?? nowIso(), - sessionId: args.sessionId ?? `coordinator:${args.runId}`, - runId: args.runId, - attemptId: null, - laneId: args.laneId ?? null, - event: args.event, - }); - }; - - const normalizeWorkerProgressPreviewForChat = (preview: string): string | null => { - const compact = preview - .replace(/\u001b\[[0-9;]*m/g, "") - .replace(/([.!?])([A-Z])/g, "$1 $2") - .replace(/\s+/g, " ") - .trim(); - if (!compact.length) return null; - if (isWorkerBootstrapNoiseLine(compact)) return null; - - if ( - /^You are an ADE /i.test(compact) - || /^Mission goal:/i.test(compact) - || /^Mission Plan:/i.test(compact) - || /^Step instructions:/i.test(compact) - || /^Phase-level guidance:/i.test(compact) - || /^Referenced docs:/i.test(compact) - || /^tool ade\./i.test(compact) - || /^"(?:workerId|text|content|summary|outcome|stepId|stepKey|laneId|reportedAt|type)"\s*:/i.test(compact) - || /^\{\s*"ok"\s*:/i.test(compact) - || /^quote>\s*/i.test(compact) - || /^-p\s+"\$\(cat\s+/i.test(compact) - || /^cp\s+'.+worker-[a-f0-9-]+\.json'\s+'.+\.json'(\s+&&\s+exec\b.*)?$/i.test(compact) - || /^ade_[a-z0-9_]+=.+/i.test(compact) - || /(?:^|[\\/])(?:orchestrator[\\/])?worker-prompts[\\/]worker-[a-f0-9-]+(?:\.[A-Za-z0-9._-]+)?/i.test(compact) - || /\.ade-worker-[a-f0-9-]+\.json/i.test(compact) - || /(?:^|[-*]\s+)?`?\.ade\/(?:step-output|checkpoints)-worker_[^`\s]+\.md`?/i.test(compact) - || /[A-Za-z0-9._-]+\.(?:txt|json)['")]+$/i.test(compact) - || /^"(?:missionId|runId|stepId|stepKey|laneId|attemptId)\b/i.test(compact) - || /^"(?:[A-Za-z0-9_]+)"\s*:\s*/i.test(compact) - || /^[A-Za-z0-9_./-]+\.(?:ts|tsx|js|jsx|json|md|txt):\d+(?::\d+)?:/i.test(compact) - || /^(?:[-*]\s+)?(?:\.{0,2}[\\/]|\/)[^\s]+$/.test(compact) - || /^(?:[-*]\s+)?(?:[A-Za-z0-9_.-]+[\\/])+[A-Za-z0-9_.-]+\/?$/.test(compact) - || /^\]\s+as\s+[A-Za-z]/i.test(compact) - || /^EOF"\s+in\s+\/Users\//i.test(compact) - || /^\/users\/.+\.zshrc:\d+:/i.test(compact) - || /command not found:\s*compdef/i.test(compact) - || /exec claude --model/i.test(compact) - || /exec codex\b/i.test(compact) - || /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+\s+.+\s[%#$]$/.test(compact) - ) { - return null; - } - - const fileUpdateMatch = compact.match(/^-?\s*(Created|Updated|Added|Removed)\s+\[([^\]]+)\]/i); - if (fileUpdateMatch) { - const verb = fileUpdateMatch[1].charAt(0).toUpperCase() + fileUpdateMatch[1].slice(1).toLowerCase(); - const fileName = nodePath.basename(fileUpdateMatch[2].trim()); - return `${verb} ${fileName}.`; - } - - if ( - /^(?:\/bin\/(?:zsh|bash)|zsh|bash|git|pnpm|npm|npx|node|sed|rg|cat|ls|cp|mv|rm|apply_patch)\b/i.test(compact) - || /^(?:diff --git|index [0-9a-f]{7,}\.\.[0-9a-f]{7,}|@@|--- |\+\+\+ )/i.test(compact) - || /^[+\-@{}[\]()<>|]+$/.test(compact) - || /^[+-]\s*(?:<[/A-Za-z]|import\b|export\b|const\b|function\b|return\b)/.test(compact) - || /^(?:<[/A-Za-z]|import\b|export\b|const\b|function\b|return\b)/.test(compact) - ) { - return null; - } - - const alphaChars = compact.match(/[A-Za-z]/g)?.length ?? 0; - if (alphaChars < 6) return null; - return clipTextForContext(compact, 180); - }; - - const maybeEmitWorkerProgressChatUpdate = (args: { - missionId: string; - runId: string; - step: OrchestratorStep; - attemptId: string; - sessionId: string; - laneId: string | null; - preview: string; - }): void => { - if (structuredChatSessions.has(args.sessionId)) return; - const content = normalizeWorkerProgressPreviewForChat(args.preview); - if (!content) return; - - const digest = digestSignalText(content); - const nowMs = Date.now(); - const lastState = workerProgressChatState.get(args.attemptId) ?? { - lastDigest: null, - lastEmittedAtMs: 0, - }; - const sameDigest = digest != null && lastState.lastDigest === digest; - const minIntervalMs = sameDigest - ? WORKER_PROGRESS_CHAT_REPEAT_INTERVAL_MS - : WORKER_PROGRESS_CHAT_MIN_INTERVAL_MS; - if (nowMs - lastState.lastEmittedAtMs < minIntervalMs) return; - - workerProgressChatState.set(args.attemptId, { - lastDigest: digest, - lastEmittedAtMs: nowMs, - }); - - emitWorkerThreadMessage({ - missionId: args.missionId, - runId: args.runId, - step: args.step, - attemptId: args.attemptId, - sessionId: args.sessionId, - laneId: args.laneId, - content, - metadata: { - source: "runtime_signal_progress", - }, - }); - }; - - const normalizeCoordinatorToolEventName = (toolName: string): string => { - return toolName.trim(); - }; - - const derivePlannerLifecycleFromDelegation = (contract: DelegationContract): { - state: CoordinatorLifecycleState; - message: string; - } | null => { - if (contract.workerIntent !== "planner") return null; - if (contract.launchState === "fetching_context") { - return { - state: "fetching_project_context", - message: "I’m pulling project context so the planner starts with the right picture.", - }; - } - if (contract.status === "launching" || contract.launchState === "awaiting_worker_launch" || contract.launchState === "launching_worker") { - return { - state: "launching_planner", - message: "I’m starting the planning agent now.", - }; - } - if (contract.status === "active" && contract.launchState === "waiting_on_worker") { - const launched = contract.metadata && isRecord(contract.metadata) ? contract.metadata.launched !== false : true; - return { - state: "waiting_on_planner", - message: launched - ? "The planning agent is running. I’m waiting for its result." - : "The planning agent is queued. I’m waiting for it to start.", - }; - } - if (contract.status === "launch_failed" || contract.status === "blocked" || contract.status === "failed") { - return { - state: "planner_launch_failed", - message: "The planner hit a launch issue, so I paused the run.", - }; - } - return null; - }; - - const syncCoordinatorLifecycleFromEvent = (args: { - missionId: string; - runId: string; - planningIsFirstPhase: boolean; - currentPhaseKey: string; - event: AgentChatEvent; - }): string | null => { - if (args.event.type === "tool_result" && normalizeCoordinatorToolEventName(args.event.tool) === "set_current_phase") { - const result = asRecord(args.event.result); - if (result?.ok === true) { - const phaseKey = toOptionalString(result.currentPhaseKey); - const phaseName = toOptionalString(result.currentPhaseName) - ?? phaseKey - ?? "current"; - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "running_phase", - message: `The coordinator is running the ${phaseName} phase.`, - delegation: null, - }); - return phaseKey?.trim().toLowerCase() || null; - } - return null; - } - - const activePhaseKey = args.currentPhaseKey.trim().toLowerCase(); - if (!args.planningIsFirstPhase || activePhaseKey !== "planning") return null; - if (args.event.type === "delegation_state") { - const nextLifecycle = derivePlannerLifecycleFromDelegation(args.event.contract); - if (!nextLifecycle) return null; - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: nextLifecycle.state, - message: args.event.message?.trim().length ? args.event.message.trim() : nextLifecycle.message, - delegation: args.event.contract, - }); - return null; - } - if (args.event.type === "tool_call") { - const toolName = normalizeCoordinatorToolEventName(args.event.tool); - if (toolName === "get_project_context") { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "fetching_project_context", - message: "I’m pulling project context so the planner starts with the right picture.", - }); - } else if (toolName === "spawn_worker") { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "launching_planner", - message: "I’m starting the planning agent now.", - }); - } - return null; - } - - if (args.event.type === "tool_result") { - const toolName = normalizeCoordinatorToolEventName(args.event.tool); - if (toolName !== "spawn_worker") return null; - const result = asRecord(args.event.result); - if (result?.ok === true) { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "waiting_on_planner", - message: result?.launched === false - ? "The planning agent is queued. I’m waiting for it to start." - : "The planning agent is running. I’m waiting for its result.", - }); - } else if (result?.ok === false) { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "planner_launch_failed", - message: "The planner hit a launch issue, so I’m diagnosing that now.", - }); - } - return null; - } - - if (args.event.type === "status" && typeof args.event.message === "string" && args.event.message.trim().length > 0) { - const message = args.event.message.trim(); - if (/retrying once/i.test(message)) { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "launching_planner", - message, - }); - } else if (/waiting for (?:it to start|its result)/i.test(message)) { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "waiting_on_planner", - message, - }); - } else if (/paused the run/i.test(message)) { - emitCoordinatorLifecycle({ - missionId: args.missionId, - runId: args.runId, - state: "planner_launch_failed", - message, - }); - } - } - return null; - }; - - // ── Coordinator Session Management ────────────────────────────── - // Spins up a persistent AI coordinator session for a mission run. - // The coordinator observes runtime events and can intervene with - // steer/skip/broadcast commands. Uses aiIntegrationService.executeTask - // with sessionId for multi-turn conversation. - - - // ── V2 Coordinator Agent Lifecycle ────────────────────────────── - - const startCoordinatorAgentV2 = ( - missionId: string, - runId: string, - missionGoal: string, - modelConfig: ModelConfig, - opts?: { - userRules?: CoordinatorUserRules; - projectContext?: CoordinatorProjectContext; - availableProviders?: CoordinatorAvailableProvider[]; - phases?: PhaseCard[]; - skipInitialActivationMessage?: boolean; - missionLaneId?: string; - }, - ): CoordinatorAgent | null => { - const startCooldownMs = 10_000; - const nowMs = Date.now(); - const recentFailure = coordinatorStartFailureCooldowns.get(runId); - if (recentFailure && nowMs - recentFailure.lastFailedAtMs < startCooldownMs) { - logger.debug("ai_orchestrator.coordinator_agent_v2_start_suppressed", { - missionId, - runId, - count: recentFailure.count, - error: recentFailure.message, - }); - return null; - } - if (coordinatorStartInFlight.has(runId)) { - logger.debug("ai_orchestrator.coordinator_agent_v2_start_suppressed", { - missionId, - runId, - reason: "start_in_flight", - }); - return null; - } - if (!projectRoot) { - logger.debug("ai_orchestrator.coordinator_agent_v2_skip", { - missionId, - runId, - reason: "no_project_root", - }); - return null; - } - - coordinatorStartInFlight.add(runId); - try { - const coordinatorProjectId = resolveProjectIdForMission(missionId) ?? missionId; - const modelId = modelConfigToServiceModel(modelConfig); - const workspaceRoot = (() => { - const laneId = opts?.missionLaneId ?? resolvePersistedMissionLaneIdForRun(runId); - if (laneId && laneService && typeof laneService.getLaneWorktreePath === "function") { - try { - return laneService.getLaneWorktreePath(laneId); - } catch { - // Fall back to the canonical project root below. - } - } - return projectRoot; - })(); - const initialCoordinatorPhase = Array.isArray(opts?.phases) - ? [...opts.phases].sort((a, b) => a.position - b.position)[0] ?? null - : null; - const planningIsFirstPhase = initialCoordinatorPhase?.phaseKey.trim().toLowerCase() === "planning"; - const initialCoordinatorPhaseKey = (() => { - try { - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const phaseRuntime = asRecord(graph.run.metadata?.phaseRuntime); - const currentPhaseKey = toOptionalString(phaseRuntime?.currentPhaseKey)?.trim().toLowerCase(); - if (currentPhaseKey) return currentPhaseKey; - } catch { - // Fall back to the first configured phase while the run is still being initialized. - } - return initialCoordinatorPhase?.phaseKey.trim().toLowerCase() ?? ""; - })(); - let coordinatorLifecyclePhaseKey = initialCoordinatorPhaseKey; - const agent = new CoordinatorAgent({ - orchestratorService, - missionService, - runId, - missionId, - missionGoal, - modelId, - logger, - db, - projectId: coordinatorProjectId, - projectRoot, - workspaceRoot, - aiIntegrationService: aiIntegrationService ?? null, - agentChatService: agentChatService ?? null, - coordinatorReasoningEffort: thinkingLevelToReasoningEffort(modelConfig.thinkingLevel), - projectConfigService: projectConfigService ?? null, - getMissionBudgetStatus: missionBudgetService - ? async () => { - try { - return await missionBudgetService.getMissionBudgetStatus({ missionId, runId }); - } catch { - return null; - } - } - : undefined, - onDagMutation: (event) => { - if (onDagMutation) onDagMutation(event); - }, - onCoordinatorEvent: (event) => { - const nextLifecyclePhaseKey = syncCoordinatorLifecycleFromEvent({ - missionId, - runId, - planningIsFirstPhase, - currentPhaseKey: coordinatorLifecyclePhaseKey, - event, - }); - if (nextLifecyclePhaseKey) coordinatorLifecyclePhaseKey = nextLifecyclePhaseKey; - persistStructuredCoordinatorChatEvent({ missionId, runId, event }); - }, - onCoordinatorChatSessionStarted: ({ sessionId, missionId: startedMissionId, runId: startedRunId, laneId }) => { - const previousSessionId = coordinatorChatSessionByRunId.get(startedRunId); - if (previousSessionId && previousSessionId !== sessionId) { - coordinatorChatSessions.delete(previousSessionId); - } - coordinatorChatSessionByRunId.set(startedRunId, sessionId); - coordinatorChatSessions.set(sessionId, { - missionId: startedMissionId, - runId: startedRunId, - laneId, - }); - upsertThread({ - missionId: startedMissionId, - threadId: `mission:${startedMissionId}`, - threadType: "coordinator", - title: "Orchestrator", - target: { - kind: "coordinator", - runId: startedRunId, - sessionId, - laneId, - }, - status: "active", - }); - }, - onCoordinatorChatRuntimeTurnStarted: ({ sessionId }) => { - if (sessionId.trim().length > 0) { - coordinatorChatRuntimeOwnedSessions.add(sessionId); - } - }, - onCoordinatorChatRuntimeTurnFinished: ({ sessionId }) => { - coordinatorChatRuntimeOwnedSessions.delete(sessionId); - }, - onRunFinalize: (args) => { - // Forward coordinator's verdict to the chat before finalizing - if (args.succeeded) { - emitOrchestratorMessage(missionId, `[Mission Complete] ${args.summary ?? "Mission finished successfully."}`, null, { - role: "coordinator_v2", - runId, - event: "mission_complete", - }); - void syncMissionFromRun(runId, "coordinator_complete_mission", { - nextMissionStatus: "completed", - }); - } else { - emitOrchestratorMessage(missionId, `[Mission Failed] ${args.reason ?? "Mission could not be completed."}`, null, { - role: "coordinator_v2", - runId, - event: "mission_failed", - }); - finalizeRun({ runId, force: true }); - } - }, - onHardCapTriggered: (detail) => { - pauseOnBudgetHardCap(missionId, detail); - }, - onBudgetWarning: (pressure, detail) => { - emitOrchestratorMessage( - missionId, - `Budget pressure: ${pressure} \u2014 ${detail}`, - null, - { budgetPressure: pressure, source: "budget_soft_warning" } - ); - }, - sendWorkerMessageToSession: async ({ sessionId, text, priority }) => - sendWorkerMessageToSessionWithStatusCtx(ctx, sessionId, text, { priority }), - enableCompaction: true, - onPlanningStartupFailure: (failure: CoordinatorPlanningStartupFailure) => { - emitCoordinatorLifecycle({ - missionId, - runId, - state: "planner_launch_failed", - message: failure.category === "permission_denied" - ? "The planner was blocked by a permission issue, so I paused the run." - : failure.category === "provider_unreachable" - ? "The planner hit a launch issue, so I paused the run and opened recovery options." - : "The planner hit a launch issue, so I paused the run.", - }); - pauseRunWithIntervention({ - runId, - missionId, - source: "transition_decision", - interventionType: failure.interventionType, - reasonCode: failure.reasonCode, - title: failure.title, - body: failure.body, - requestedAction: failure.requestedAction, - metadata: { - category: failure.category, - retryable: failure.retryable, - recoveryOptions: failure.recoveryOptions, - toolName: failure.toolName ?? null, - retryCount: failure.retryCount, - }, - }); - }, - onCoordinatorRuntimeFailure: (failure: CoordinatorRuntimeFailure) => { - const lifecycleMessage = failure.reasonCode === "coordinator_runtime_provider_auth_failed" - ? "The orchestrator could not authenticate with the selected provider, so I paused the run." - : failure.category === "provider_unreachable" - ? "The orchestrator lost contact with the selected provider, so I paused the run." - : failure.category === "permission_denied" - ? "The orchestrator was blocked by permissions, so I paused the run." - : failure.category === "cli_runtime_failure" - ? "The orchestrator process exited unexpectedly, so I paused the run." - : "The orchestrator stopped unexpectedly, so I paused the run."; - emitCoordinatorLifecycle({ - missionId, - runId, - state: "stopped", - message: lifecycleMessage, - force: true, - }); - pauseRunWithIntervention({ - runId, - missionId, - source: "transition_decision", - interventionType: failure.interventionType, - reasonCode: failure.reasonCode, - title: failure.title, - body: failure.body, - requestedAction: failure.requestedAction, - metadata: { - category: failure.category, - retryable: failure.retryable, - recoveryOptions: failure.recoveryOptions, - turnId: failure.turnId, - modelId, - error: failure.message, - coordinatorFailureHandled: true, - }, - }); - endCoordinatorAgentV2(runId); - }, - userRules: opts?.userRules, - projectContext: opts?.projectContext, - availableProviders: opts?.availableProviders, - phases: opts?.phases, - missionLaneId: opts?.missionLaneId, - provisionLane: laneService - ? async (name: string, description?: string) => { - const result = await createLaneFromBase(name, { description, folder: `Mission: ${missionId}`, missionId }); - if (!result) throw new Error("No base lane available for provisioning."); - return result; - } - : undefined, - }); - - coordinatorAgents.set(runId, agent); - - // Inject the mission prompt — the coordinator takes it from here - if (!opts?.skipInitialActivationMessage) { - const laneContext = opts?.missionLaneId - ? `\n\nA primary mission lane has been created for you (lane ID: ${opts.missionLaneId}). All workers should be assigned to this lane or to additional lanes you create with provision_lane. NEVER assign workers to the base lane directly.` - : ""; - agent.injectMessage( - planningIsFirstPhase - ? `You have been activated. Your mission:\n\n${missionGoal}\n\nPlanning is the active first phase. If no planning questions are needed, immediately call get_project_context, spawn the planning worker in read-only mode, and then wait for its output instead of continuing to reason on your own.${laneContext}` - : `You have been activated. Your mission:\n\n${missionGoal}\n\nYou have full authority. Read the mission, create tasks, spawn workers, and complete the mission. Delegate quickly, then stay idle until a worker produces meaningful new information.${laneContext}`, - ); - } - - logger.info("ai_orchestrator.coordinator_agent_v2_started", { - missionId, - runId, - modelId, - }); - - coordinatorStartFailureCooldowns.delete(runId); - return agent; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const previous = coordinatorStartFailureCooldowns.get(runId); - coordinatorStartFailureCooldowns.set(runId, { - count: (previous?.count ?? 0) + 1, - lastFailedAtMs: Date.now(), - message, - }); - logger.warn("ai_orchestrator.coordinator_agent_v2_start_failed", { - missionId, - runId, - error: message, - }); - return null; - } finally { - coordinatorStartInFlight.delete(runId); - } - }; - - const endCoordinatorAgentV2 = (runId: string): void => { - const agent = coordinatorAgents.get(runId); - const missionId = getMissionIdForRun(runId); - const coordinatorSessionId = coordinatorChatSessionByRunId.get(runId); - if (coordinatorSessionId) { - coordinatorChatSessionByRunId.delete(runId); - coordinatorChatSessions.delete(coordinatorSessionId); - structuredChatSessions.delete(coordinatorSessionId); - } - if (agent) { - agent.shutdown(); - coordinatorAgents.delete(runId); - coordinatorRecoveryAttempts.delete(runId); - logger.info("ai_orchestrator.coordinator_agent_v2_ended", { - runId, - turns: agent.turns, - historyLength: agent.historyLength, - }); - } - if (missionId) { - closeCoordinatorThread(missionId, runId); - } - }; - - /** - * Attempt to recover a dead coordinator agent. Returns the new agent or null if recovery fails. - * Limited to maxCoordinatorRecoveries (configurable, default 3) per run to prevent infinite restart loops. - */ - const attemptCoordinatorRecovery = (runId: string): CoordinatorAgent | null => { - const attempts = coordinatorRecoveryAttempts.get(runId) ?? 0; - const missionId = getMissionIdForRun(runId); - const maxRecoveries = getMaxCoordinatorRecoveries(missionId); - if (attempts >= maxRecoveries) { - logger.warn("ai_orchestrator.coordinator_recovery_exhausted", { runId, attempts, maxRecoveries }); - - // Surface as a user intervention instead of silently returning null - if (missionId) { - pauseMissionWithIntervention({ - missionId, - interventionType: "unrecoverable_error", - title: "Coordinator recovery exhausted", - body: `The coordinator has exhausted recovery attempts (${attempts}/${maxRecoveries}). The mission is paused. You can resume to retry, or cancel.`, - requestedAction: "Resume the mission to retry coordinator startup, adjust maxCoordinatorRecoveries in config, or cancel.", - metadata: { source: "coordinator_recovery_exhausted", runId, attempts, maxRecoveries }, - }); - } - - return null; - } - - const nextAttempt = attempts + 1; - coordinatorRecoveryAttempts.set(runId, nextAttempt); - - try { - // Clean up the dead agent - const deadAgent = coordinatorAgents.get(runId); - if (deadAgent) { - deadAgent.shutdown(); - coordinatorAgents.delete(runId); - } - - // Get mission context - const missionId = getMissionIdForRun(runId); - if (!missionId) return null; - - const mission = missionService.get(missionId); - if (!mission) return null; - - const missionGoal = mission.prompt || mission.title; - const recoveredMissionLaneId = resolvePersistedMissionLaneIdForRun(runId); - if (recoveredMissionLaneId) { - persistMissionLaneIdForRun(runId, recoveredMissionLaneId); - } - const coordinatorModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); - const { userRules, projectCtx, availableProviders, phases } = gatherCoordinatorContext(missionId, { missionId }); - - // Restart coordinator - const newAgent = startCoordinatorAgentV2(missionId, runId, missionGoal, coordinatorModelConfig, { - userRules, - projectContext: projectCtx, - availableProviders, - phases, - missionLaneId: recoveredMissionLaneId ?? undefined, - }); - - if (!newAgent) return null; - - // Build recovery context from current run state - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const stepSummaries = graph.steps.map((s) => ` - ${s.stepKey} (${s.title}): ${s.status}`).join("\n"); - const runningWorkers = graph.attempts - .filter((a) => a.status === "running") - .map((a) => { - const step = graph.steps.find((s) => s.id === a.stepId); - return ` - ${step?.stepKey ?? a.stepId}: running (session: ${a.executorSessionId ?? "unknown"})`; - }) - .join("\n"); - const completedWorkers = graph.attempts - .filter((a) => a.status === "succeeded" || a.status === "failed") - .slice(-10) // Last 10 completed - .map((a) => { - const step = graph.steps.find((s) => s.id === a.stepId); - return ` - ${step?.stepKey ?? a.stepId}: ${a.status}${a.resultEnvelope?.summary ? ` — ${a.resultEnvelope.summary.slice(0, 200)}` : ""}`; - }) - .join("\n"); - - newAgent.injectMessage( - `[COORDINATOR RECOVERY] You are taking over a mission that was in progress. -The previous coordinator crashed (recovery attempt ${nextAttempt} of ${maxRecoveries}). Here is the current state: - -Mission: ${missionGoal} - -Steps: -${stepSummaries || " (none)"} - -Running workers: -${runningWorkers || " (none)"} - -Recent completed workers: -${completedWorkers || " (none)"} - -Check all worker statuses and continue managing the mission from here. Read worker outputs for any recently completed workers to understand what's been done.`, - ); - - logger.info("ai_orchestrator.coordinator_recovered", { - runId, - missionId, - recoveryAttempt: nextAttempt, - }); - - emitOrchestratorMessage( - missionId, - `Coordinator recovered (attempt ${nextAttempt}/${maxRecoveries}). Resuming mission control.`, - null, - { role: "coordinator_v2", runId }, - ); - resolveCoordinatorHealthInterventions({ - runId, - note: `Coordinator recovered and resumed mission control on attempt ${nextAttempt}/${maxRecoveries}.`, - resolutionReason: "coordinator_recovered", - }); - - return newAgent; - } catch (error) { - logger.warn("ai_orchestrator.coordinator_recovery_failed", { - runId, - recoveryAttempt: nextAttempt, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }; - - const emitOrchestratorMessage = ( - missionId: string, - content: string, - stepKey?: string | null, - metadata?: Record | null - ): OrchestratorChatMessage => { - const runId = typeof metadata?.runId === "string" ? metadata.runId : null; - const role = typeof metadata?.role === "string" ? metadata.role : null; - if (runId && role === "coordinator_v2" && shouldSuppressCoordinatorWrites(runId)) { - return { - id: randomUUID(), - missionId, - role: "orchestrator", - content, - timestamp: nowIso(), - stepKey: stepKey ?? null, - metadata: metadata ?? null, - }; - } - return emitOrchestratorMessageCtx(ctx, missionId, content, stepKey, metadata, { appendChatMessage }); - }; - - const emitValidationSystemSignal = (args: { - event: OrchestratorRuntimeEvent; - graph: OrchestratorRunGraph; - }): void => { - const signalType = - args.event.reason === "validation_contract_unfulfilled" || - args.event.reason === "validation_self_check_reminder" || - args.event.reason === "validation_auto_spawned" || - args.event.reason === "validation_gate_blocked" - ? args.event.reason - : null; - if (!signalType || !args.event.runId) return; - const missionId = getMissionIdForRun(args.event.runId); - if (!missionId) return; - - const dedupeKey = `${signalType}:${args.event.runId}:${args.event.stepId ?? "none"}:${args.event.attemptId ?? "none"}`; - if (validationSystemSignalDedupe.has(dedupeKey)) return; - validationSystemSignalDedupe.add(dedupeKey); - if (validationSystemSignalDedupe.size > 500) { - // Evict oldest half instead of full clear to preserve recent dedup keys - const entries = [...validationSystemSignalDedupe]; - validationSystemSignalDedupe.clear(); - for (const key of entries.slice(entries.length >> 1)) { - validationSystemSignalDedupe.add(key); - } - } - - const step = args.event.stepId - ? args.graph.steps.find((entry) => entry.id === args.event.stepId) ?? null - : null; - const stepLabel = step?.title?.trim().length ? step.title.trim() : (step?.stepKey ?? "unknown step"); - let message = "Validation system event received."; - const metadata: Record = { - systemSignal: signalType, - runId: args.event.runId, - stepId: args.event.stepId ?? null, - attemptId: args.event.attemptId ?? null - }; - if (step?.stepKey) { - metadata.stepKey = step.stepKey; - } - - if (signalType === "validation_contract_unfulfilled") { - message = `Validation System: Required validation is missing for "${stepLabel}". A validator pass is required before this step can be treated as complete.`; - } else if (signalType === "validation_self_check_reminder") { - message = `Validation System: "${stepLabel}" requires self-validation. Call report_validation with verdict pass/fail to unblock downstream work.`; - } else if (signalType === "validation_gate_blocked") { - const gateEvent = args.graph.timeline.find((entry) => { - if (entry.eventType !== "validation_gate_blocked") return false; - if (args.event.stepId && entry.stepId) return entry.stepId === args.event.stepId; - return true; - }); - const detail = isRecord(gateEvent?.detail) ? gateEvent.detail : {}; - const reasonText = typeof detail.reason === "string" ? detail.reason.trim() : ""; - const phaseText = typeof detail.phase === "string" ? detail.phase.trim() : ""; - if (reasonText.length > 0) metadata.reason = reasonText; - if (phaseText.length > 0) metadata.phase = phaseText; - message = reasonText.length > 0 - ? `Validation System: Worker spawn blocked by required validation gate. ${reasonText}` - : "Validation System: Worker spawn blocked by required validation gate."; - } - - emitOrchestratorMessage( - missionId, - message, - step?.stepKey ?? null, - metadata - ); - }; - - const MISSION_STATE_TERMINAL_STEP_STATUSES = new Set([ - "succeeded", - "failed", - "skipped", - "superseded", - "canceled", - ]); - - const toMissionStateStepStatus = (status: OrchestratorStepStatus): MissionStateStepOutcome["status"] => { - if (status === "succeeded") return "succeeded"; - if (status === "failed") return "failed"; - if (status === "skipped" || status === "superseded" || status === "canceled") return "skipped"; - return "in_progress"; - }; - - const missionGoalForStateDoc = (missionId: string): string => { - const mission = missionService.get(missionId); - const prompt = typeof mission?.prompt === "string" ? mission.prompt.trim() : ""; - if (prompt.length > 0) return prompt; - const title = typeof mission?.title === "string" ? mission.title.trim() : ""; - if (title.length > 0) return title; - return `Mission ${missionId}`; - }; - - const currentPhaseFromGraph = (graph: OrchestratorRunGraph): string => { - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : {}; - const phaseRuntime = isRecord(runMeta.phaseRuntime) ? runMeta.phaseRuntime : {}; - const phaseName = typeof phaseRuntime.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - if (phaseName.length > 0) return phaseName; - const phaseKey = typeof phaseRuntime.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - if (phaseKey.length > 0) return phaseKey; - const relevantSteps = filterExecutionSteps(graph.steps); - const activeStep = relevantSteps.find((step) => !MISSION_STATE_TERMINAL_STEP_STATUSES.has(step.status)) ?? null; - const activeMeta = isRecord(activeStep?.metadata) ? activeStep.metadata : {}; - const activePhaseName = typeof activeMeta.phaseName === "string" ? activeMeta.phaseName.trim() : ""; - if (activePhaseName.length > 0) return activePhaseName; - const activePhaseKey = typeof activeMeta.phaseKey === "string" ? activeMeta.phaseKey.trim() : ""; - if (activePhaseKey.length > 0) return activePhaseKey; - return "unknown"; - }; - - const buildMissionStateProgressFromGraph = (graph: OrchestratorRunGraph): MissionStateProgress => { - const relevantSteps = filterExecutionSteps(graph.steps); - let completedSteps = 0; - const activeWorkers: string[] = []; - const blockedSteps: string[] = []; - const failedSteps: string[] = []; - for (const step of relevantSteps) { - if (MISSION_STATE_TERMINAL_STEP_STATUSES.has(step.status)) { - completedSteps += 1; - } - if (step.status === "running") { - activeWorkers.push(step.stepKey); - } else if (step.status === "blocked") { - blockedSteps.push(step.stepKey); - } else if (step.status === "failed") { - failedSteps.push(step.stepKey); - } - } - return { - currentPhase: currentPhaseFromGraph(graph), - completedSteps, - totalSteps: relevantSteps.length, - activeWorkers, - blockedSteps, - failedSteps, - }; - }; - - const pendingInterventionsForMission = (missionId: string): MissionStatePendingIntervention[] => { - const mission = missionService.get(missionId); - if (!mission) return []; - return mission.interventions - .filter((entry) => entry.status === "open") - .map((entry) => ({ - id: entry.id, - type: entry.interventionType, - title: entry.title, - createdAt: entry.createdAt, - })); - }; - - const updateMissionStateDoc = ( - runId: string, - patch: MissionStateDocumentPatch, - options?: { graph?: OrchestratorRunGraph | null } - ): void => { - void persistMissionStateDoc(runId, patch, options); - }; - - const persistMissionStateDoc = async ( - runId: string, - patch: MissionStateDocumentPatch, - options?: { graph?: OrchestratorRunGraph | null } - ): Promise => { - if (!projectRoot) return; - const graph = - options?.graph ?? - (() => { - try { - return orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - } catch { - return null; - } - })(); - const missionId = graph?.run.missionId ?? getMissionIdForRun(runId); - if (!missionId) return; - const progress = graph ? buildMissionStateProgressFromGraph(graph) : undefined; - const normalizedPatch: MissionStateDocumentPatch = { - ...patch, - ...(progress - ? { - updateProgress: { - ...progress, - ...(patch.updateProgress ?? {}), - }, - } - : {}), - }; - await updateMissionStateDocument({ - projectRoot, - missionId, - runId, - goal: missionGoalForStateDoc(missionId), - patch: normalizedPatch, - initialProgress: progress, - }).catch((error) => { - logger.debug("ai_orchestrator.mission_state_update_failed", { - runId, - missionId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }; - - const NULL_FINALIZATION_FIELDS = { - targetBranch: null, - draft: null, - prDepth: null, - autoRebase: null, - ciGating: null, - autoLand: null, - autoResolveConflicts: null, - archiveLaneOnLand: null, - mergeMethod: null, - conflictResolverModel: null, - reasoningEffort: null, - } as const; - - const RESULT_LANE_FINALIZATION_POLICY: MissionFinalizationPolicy = { - ...NULL_FINALIZATION_FIELDS, - kind: "result_lane", - description: "Assemble a single result lane for the mission and stop before external PR creation." - }; - - const resolveMissionFinalizationPolicyForMission = ( - _missionId: string, - ): MissionFinalizationPolicy => RESULT_LANE_FINALIZATION_POLICY; - - const EVIDENCE_TO_CLOSEOUT_KEYS = new Set([ - "planning_document", "research_summary", "changed_files_summary", - "test_report", "review_summary", "risk_notes", "final_outcome_summary", - "screenshot", "browser_verification", "video_recording", "browser_trace", "console_logs", - ]); - - const mapEvidenceRequirementToCloseoutKey = ( - requirement: ValidationEvidenceRequirement - ): MissionCloseoutRequirementKey | null => - EVIDENCE_TO_CLOSEOUT_KEYS.has(requirement) ? requirement as MissionCloseoutRequirementKey : null; - - const buildMissionCloseoutRequirements = (args: { - mission: MissionDetail; - graph: OrchestratorRunGraph; - policy: MissionFinalizationPolicy; - finalization: MissionFinalizationState | null; - stateDoc: MissionStateDocument | null; - }): MissionCloseoutRequirement[] => { - const backendStatus = computerUseArtifactBrokerService?.getBackendStatus() ?? null; - const computerUseKinds = new Set(getComputerUseArtifactKinds()); - const hasExternalCoverage = (requirementKey: MissionCloseoutRequirementKey): boolean => - computerUseKinds.has(requirementKey as ReturnType[number]) - ? ( - backendStatus?.backends.some((backend) => - backend.available && backend.supportedKinds.includes( - requirementKey as ReturnType[number] - ) - ) ?? false - ) - : false; - const outcomeSummary = buildOutcomeSummary(args.graph).trim(); - const runtimeSummaryStatus: MissionCloseoutRequirementStatus = args.graph.run.status === "succeeded" && outcomeSummary.length > 0 - ? "present" - : (args.graph.run.status === "failed" || args.graph.run.status === "canceled") && outcomeSummary.length > 0 - ? "incomplete" - : "missing"; - const modifiedFiles = args.stateDoc?.modifiedFiles ?? []; - const completionDiagnostics = args.graph.completionEvaluation?.diagnostics ?? []; - const explicitValidationVerdict = args.graph.completionEvaluation?.validation?.canComplete; - const persistedCompletionValidation = isRecord(args.graph.run.metadata?.completionValidation) - ? args.graph.run.metadata.completionValidation - : null; - const persistedValidationVerdict = typeof persistedCompletionValidation?.canComplete === "boolean" - ? persistedCompletionValidation.canComplete - : undefined; - const stepMetadata = args.graph.steps - .map((step) => isRecord(step.metadata) ? step.metadata : {}) - .filter((meta) => Object.keys(meta).length > 0); - const requiredValidationStepMetadata = args.graph.steps.flatMap((step) => { - const meta = isRecord(step.metadata) ? step.metadata : null; - if (!meta || Object.keys(meta).length === 0) return []; - const validationContract = isRecord(meta.validationContract) ? meta.validationContract : null; - const isValidationWorker = - step.stepKey.startsWith("validate_") - || step.title.trim().toLowerCase().startsWith("validate:"); - return validationContract?.required === true && !isValidationWorker ? [meta] : []; - }); - const hasRuntimeValidationPass = (meta: Record): boolean => { - const lastValidationReport = isRecord(meta.lastValidationReport) ? meta.lastValidationReport : null; - const lastVerdict = typeof lastValidationReport?.verdict === "string" ? lastValidationReport.verdict.trim().toLowerCase() : ""; - const validationState = typeof meta.validationState === "string" ? meta.validationState.trim().toLowerCase() : ""; - const validationPassedAt = typeof meta.validationPassedAt === "string" ? meta.validationPassedAt.trim() : ""; - const isPass = (value: string) => value === "pass" || value === "passed" || value === "success" || value === "succeeded"; - return isPass(lastVerdict) || isPass(validationState) || validationPassedAt.length > 0; - }; - const runtimeValidationVerdict = requiredValidationStepMetadata.length > 0 - ? requiredValidationStepMetadata.every(hasRuntimeValidationPass) - : undefined; - const validationPhaseSucceeded = completionDiagnostics.some( - (diagnostic) => diagnostic.phase === "validation" && diagnostic.code === "phase_succeeded" - ); - const validationPhaseBlocked = completionDiagnostics.some( - (diagnostic) => diagnostic.phase === "validation" && diagnostic.blocking - ); - const validationVerdict = typeof explicitValidationVerdict === "boolean" - ? explicitValidationVerdict - : typeof persistedValidationVerdict === "boolean" - ? persistedValidationVerdict - : typeof runtimeValidationVerdict === "boolean" - ? runtimeValidationVerdict - : validationPhaseSucceeded - ? true - : validationPhaseBlocked - ? false - : undefined; - const missionArtifacts = args.mission.artifacts ?? []; - const orchestratorArtifacts = orchestratorService.getArtifactsForMission(args.mission.id); - const closeoutRequirements = new Map(); - type CloseoutEvidence = { - artifactId: string | null; - uri: string | null; - detail: string | null; - source: "declared" | "discovered" | "runtime"; - }; - const artifactByKey = new Map(); - const runtimeEvidenceByKey = new Map(); - - for (const artifact of missionArtifacts) { - const requirementKey = resolveCloseoutRequirementKeyFromArtifact({ - artifactType: artifact.artifactType, - metadata: artifact.metadata, - }); - if (!requirementKey) continue; - artifactByKey.set(requirementKey, { - artifactId: artifact.id, - uri: artifact.uri ?? null, - detail: artifact.description ?? null, - source: "declared", - }); - } - for (const artifact of orchestratorArtifacts) { - const requirementKey = resolveCloseoutRequirementKeyFromArtifact({ - artifactKey: artifact.artifactKey, - kind: artifact.kind, - metadata: artifact.metadata, - }); - if (!requirementKey) continue; - artifactByKey.set(requirementKey, { - artifactId: artifact.id, - uri: resolveOrchestratorArtifactUri({ - kind: artifact.kind, - value: artifact.value, - metadata: artifact.metadata, - }), - detail: typeof artifact.metadata.summary === "string" - ? artifact.metadata.summary - : typeof artifact.metadata.description === "string" - ? artifact.metadata.description - : null, - source: artifact.declared ? "declared" : "discovered", - }); - } - - const collectRiskLikeStrings = (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) collectRiskLikeStrings(entry, details); - } - }; - const collectRiskSummary = (summary: unknown, details: string[]): void => { - const normalized = typeof summary === "string" ? summary.trim() : ""; - if (normalized && /\brisks?\b|\brisk notes?\b|\bdownstream notes?\b/i.test(normalized)) { - details.push(normalized); - } - }; - const runtimeRiskDetails = stepMetadata.flatMap((meta) => { - const report = isRecord(meta.lastResultReport) ? meta.lastResultReport : null; - if (!report) return []; - 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; - }); - const runtimeRiskValidationDetails = stepMetadata.flatMap((meta) => { - const validationReport = isRecord(meta.lastValidationReport) ? meta.lastValidationReport : null; - if (!validationReport) return []; - const validationContract = isRecord(validationReport.contract) - ? validationReport.contract - : isRecord(meta.validationContract) - ? meta.validationContract - : null; - const validationPhase = isRecord(meta.phaseValidation) ? meta.phaseValidation : null; - const verdict = typeof validationReport.verdict === "string" ? validationReport.verdict.trim().toLowerCase() : ""; - const findings = Array.isArray(validationReport.findings) ? validationReport.findings : []; - const riskAwareText = [ - typeof validationReport.summary === "string" ? validationReport.summary : "", - typeof validationContract?.criteria === "string" ? validationContract.criteria : "", - typeof validationPhase?.criteria === "string" ? validationPhase.criteria : "", - ].join(" "); - if (verdict !== "pass" || findings.length > 0 || !/\brisks?\b|\brisk notes?\b|\bremaining risks?\b/i.test(riskAwareText)) { - return []; - } - const summary = typeof validationReport.summary === "string" && validationReport.summary.trim().length > 0 - ? validationReport.summary.trim() - : "Validation passed with no unresolved risk findings."; - return [`No unresolved risk findings were reported by validation: ${summary}`]; - }); - const runtimeCloseoutRiskDetails = (args.graph.runtimeEvents ?? []).flatMap((event) => { - if (event.eventType !== "done") return []; - const payload = isRecord(event.payload) ? event.payload : {}; - 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; - }); - const allRuntimeRiskDetails = [...runtimeRiskDetails, ...runtimeRiskValidationDetails, ...runtimeCloseoutRiskDetails]; - const phaseRequiresRiskNotes = (args.mission.phaseConfiguration?.selectedPhases ?? []) - .some((phase) => - phase.validationGate.required - && (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, - uri: null, - detail: clipTextForContext(allRuntimeRiskDetails[0], 600), - source: "runtime", - }); - } else if (args.graph.run.status === "succeeded" && (!phaseRequiresRiskNotes || retrospectiveHasNoUnresolvedRisks)) { - runtimeEvidenceByKey.set("risk_notes", { - artifactId: null, - uri: null, - detail: "No unresolved risks were reported by completed mission steps.", - source: "runtime", - }); - } - - 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 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); - 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 = - 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); - 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); - }; - - pushRequirement({ - key: "implementation_summary", - label: "Implementation summary", - required: true, - status: runtimeSummaryStatus, - detail: outcomeSummary.length > 0 ? outcomeSummary : "Execution finished without a final implementation summary.", - artifactId: null, - uri: null, - source: "runtime", - }); - - pushRequirement({ - key: "final_outcome_summary", - label: "Final outcome summary", - required: true, - status: runtimeSummaryStatus, - detail: outcomeSummary.length > 0 ? outcomeSummary : "No final outcome summary has been recorded yet.", - artifactId: null, - uri: null, - source: "runtime", - }); - - pushRequirement({ - key: "changed_files_summary", - label: "Changed files summary", - required: true, - status: modifiedFiles.length > 0 ? "present" : "waived", - detail: modifiedFiles.length > 0 - ? `${modifiedFiles.length} file(s) changed: ${modifiedFiles.slice(0, 12).join(", ")}` - : "No file changes were reported for this mission.", - artifactId: null, - uri: null, - source: modifiedFiles.length > 0 ? "runtime" : "waiver", - }); - - const validationRequired = (args.mission.phaseConfiguration?.selectedPhases ?? []) - .some((phase) => phase.validationGate.required); - pushRequirement({ - key: "validation_verdict", - label: "Validation verdict", - required: validationRequired, - status: validationRequired - ? typeof validationVerdict === "boolean" - ? "present" - : "missing" - : "waived", - detail: validationRequired - ? typeof validationVerdict === "boolean" - ? validationVerdict - ? "Validation passed." - : "Validation reported blockers." - : "Validation verdict has not been recorded yet." - : "No required validation gate was configured for this mission.", - artifactId: null, - uri: null, - source: validationRequired ? "runtime" : "waiver", - }); - - if (args.policy.kind !== "manual" && args.policy.kind !== "disabled" && args.policy.kind !== "result_lane") { - const reviewRequired = args.policy.prDepth === "open-and-comment"; - const proposalOnly = args.policy.prDepth === "propose-only"; - pushRequirement({ - key: proposalOnly ? "proposal_url" : "pr_url", - label: proposalOnly ? "Proposal URL" : "PR URL", - required: true, - status: proposalOnly - ? args.finalization?.proposalUrl - ? "present" - : "missing" - : (args.finalization?.prUrls.length ?? 0) > 0 - ? "present" - : "missing", - detail: proposalOnly - ? args.finalization?.proposalUrl ?? "A proposal URL has not been attached yet." - : args.finalization?.prUrls[0] ?? "A PR URL has not been attached yet.", - artifactId: null, - uri: proposalOnly ? args.finalization?.proposalUrl ?? null : args.finalization?.prUrls[0] ?? null, - source: "runtime", - }); - if (reviewRequired) { - pushRequirement({ - key: "review_summary", - label: "Review summary", - required: true, - status: args.finalization?.reviewStatus === "comment_posted" ? "present" : "missing", - detail: args.finalization?.reviewStatus === "comment_posted" - ? "ADE posted the configured review/finalization comment." - : "The configured review summary comment has not been posted yet.", - artifactId: null, - uri: args.finalization?.prUrls[0] ?? null, - source: "runtime", - }); - } - } - - const requiredEvidence = new Set(); - for (const phase of args.mission.phaseConfiguration?.selectedPhases ?? []) { - if (!phase.validationGate.required) continue; - for (const evidenceRequirement of phase.validationGate.evidenceRequirements ?? []) { - const requirementKey = mapEvidenceRequirementToCloseoutKey(evidenceRequirement); - if (requirementKey) requiredEvidence.add(requirementKey); - } - } - - for (const requirementKey of requiredEvidence) { - if (closeoutRequirements.has(requirementKey)) continue; - const artifact = artifactByKey.get(requirementKey) ?? runtimeEvidenceByKey.get(requirementKey) ?? null; - const capability = isProofEvidenceRequirement(requirementKey) - ? getCapabilityForRequirement(requirementKey) - : null; - pushRequirement({ - key: requirementKey, - label: requirementKey.replace(/_/g, " "), - required: true, - status: artifact - ? "present" - : hasExternalCoverage(requirementKey) - ? "missing" - : capability && !capability.available - ? "blocked_by_capability" - : "missing", - detail: artifact?.detail - ?? artifact?.uri - ?? ( - hasExternalCoverage(requirementKey) - ? `Required evidence "${requirementKey.replace(/_/g, " ")}" has not been attached yet, but an approved external computer-use backend is available.` - : capability && !capability.available - ? `Required evidence "${requirementKey.replace(/_/g, " ")}" is blocked because the local computer-use runtime is unavailable. ${capability.detail}` - : `Required evidence "${requirementKey.replace(/_/g, " ")}" has not been attached yet.` - ), - artifactId: artifact?.artifactId ?? null, - uri: artifact?.uri ?? null, - source: artifact?.source ?? (capability && !capability.available && !hasExternalCoverage(requirementKey) ? "runtime" : "declared"), - }); - } - - return [...closeoutRequirements.values()]; - }; - - const updateMissionFinalizationState = async ( - runId: string, - state: Partial & { policy: MissionFinalizationPolicy; status: MissionFinalizationState["status"] }, - options?: { graph?: OrchestratorRunGraph | null } - ): Promise => { - const now = nowIso(); - const stateDoc = projectRoot - ? await readMissionStateDocument({ projectRoot, runId }).catch(() => null) - : null; - const previous = stateDoc?.finalization ?? null; - const next: MissionFinalizationState = { - policy: state.policy, - status: state.status, - executionComplete: state.executionComplete ?? previous?.executionComplete ?? false, - contractSatisfied: state.contractSatisfied ?? previous?.contractSatisfied ?? false, - blocked: state.blocked ?? previous?.blocked ?? false, - blockedReason: state.blockedReason ?? previous?.blockedReason ?? null, - summary: state.summary ?? previous?.summary ?? null, - detail: state.detail ?? previous?.detail ?? null, - resolverJobId: state.resolverJobId ?? previous?.resolverJobId ?? null, - integrationLaneId: state.integrationLaneId ?? previous?.integrationLaneId ?? null, - resultLaneId: state.resultLaneId ?? previous?.resultLaneId ?? null, - queueGroupId: state.queueGroupId ?? previous?.queueGroupId ?? null, - queueId: state.queueId ?? previous?.queueId ?? null, - activePrId: state.activePrId ?? previous?.activePrId ?? null, - waitReason: state.waitReason ?? previous?.waitReason ?? null, - proposalUrl: state.proposalUrl ?? previous?.proposalUrl ?? null, - prUrls: state.prUrls ?? previous?.prUrls ?? [], - reviewStatus: state.reviewStatus ?? previous?.reviewStatus ?? null, - mergeReadiness: state.mergeReadiness ?? previous?.mergeReadiness ?? null, - requirements: state.requirements ?? previous?.requirements ?? [], - warnings: state.warnings ?? previous?.warnings ?? [], - updatedAt: now, - startedAt: state.startedAt ?? previous?.startedAt ?? now, - completedAt: state.completedAt ?? previous?.completedAt ?? null, - }; - await persistMissionStateDoc(runId, { finalization: next }, options); - return next; - }; - - const updateMissionCompletionFromStateDoc = async (args: { - runId: string; - graph: OrchestratorRunGraph; - mission: MissionDetail; - finalization: MissionFinalizationState | null; - }): Promise => { - if (!projectRoot || !args.finalization) return; - const stateDoc = await readMissionStateDocument({ projectRoot, runId: args.runId }).catch(() => null); - const closeoutRequirements = buildMissionCloseoutRequirements({ - mission: args.mission, - graph: args.graph, - policy: args.finalization.policy, - finalization: args.finalization, - stateDoc, - }); - const unmetRequirements = closeoutRequirements.filter((requirement) => - requirement.required && requirement.status !== "present" && requirement.status !== "waived", - ); - const closeoutSatisfied = unmetRequirements.length === 0; - const finalizationBlocked = args.finalization.status === "finalization_failed" || args.finalization.blocked === true; - const nextStatus = args.finalization.status === "finalization_failed" - ? "finalization_failed" - : finalizationBlocked - ? args.finalization.status - : closeoutSatisfied - ? "completed" - : args.finalization.status === "completed" - ? "finalizing" - : args.finalization.status; - const nextFinalization = await updateMissionFinalizationState(args.runId, { - policy: args.finalization.policy, - status: nextStatus, - executionComplete: true, - contractSatisfied: !finalizationBlocked && closeoutSatisfied, - requirements: closeoutRequirements, - summary: finalizationBlocked - ? args.finalization.summary - : unmetRequirements.length > 0 - ? "Execution finished, but the closeout contract is still incomplete." - : args.finalization.summary?.includes("closeout contract is still incomplete") - ? "Mission finalization completed." - : args.finalization.summary, - detail: finalizationBlocked - ? args.finalization.detail - : unmetRequirements.length > 0 - ? unmetRequirements.map((requirement) => requirement.label).join(", ") - : args.finalization.detail, - completedAt: !finalizationBlocked && closeoutSatisfied ? (args.finalization.completedAt ?? nowIso()) : null, - }, { graph: args.graph }); - if (!nextFinalization) return; - if (nextFinalization.status === "finalization_failed") { - transitionMissionStatus(args.mission.id, "failed", { - lastError: nextFinalization.blockedReason ?? nextFinalization.detail ?? "Mission finalization failed.", - }); - return; - } - if (nextFinalization.contractSatisfied) { - transitionMissionStatus(args.mission.id, "completed", { - outcomeSummary: buildOutcomeSummary(args.graph), - lastError: null, - }); - } - }; - - const setCoordinatorAvailability = ( - runId: string, - availability: MissionCoordinatorAvailability, - options?: { graph?: OrchestratorRunGraph | null } - ): void => { - updateMissionStateDoc(runId, { coordinatorAvailability: availability }, options); - }; - - const resolveMissionStateStepPhase = (step: OrchestratorStep): string => { - const stepMeta = isRecord(step.metadata) ? step.metadata : {}; - const phaseName = typeof stepMeta.phaseName === "string" ? stepMeta.phaseName.trim() : ""; - if (phaseName.length > 0) return phaseName; - const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim() : ""; - if (phaseKey.length > 0) return phaseKey; - return "unknown"; - }; - - const buildMissionStateStepOutcomeFromGraph = ( - graph: OrchestratorRunGraph, - stepId: string - ): MissionStateStepOutcome | null => { - const step = graph.steps.find((entry) => entry.id === stepId) ?? null; - if (!step) return null; - - const attemptsForStep = graph.attempts - .filter((attempt) => attempt.stepId === step.id) - .sort((a, b) => { - const aTime = Date.parse(a.completedAt ?? a.startedAt ?? a.createdAt); - const bTime = Date.parse(b.completedAt ?? b.startedAt ?? b.createdAt); - if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) { - return bTime - aTime; - } - if (a.attemptNumber !== b.attemptNumber) { - return b.attemptNumber - a.attemptNumber; - } - return b.createdAt.localeCompare(a.createdAt); - }); - const latestAttempt = attemptsForStep[0] ?? null; - const digest = latestAttempt ? buildWorkerDigestFromAttempt({ graph, attempt: latestAttempt }) : null; - const fallbackSummary = - step.status === "failed" - ? latestAttempt?.errorMessage ?? `Step ${step.stepKey} failed.` - : step.status === "succeeded" - ? `Step ${step.stepKey} completed.` - : step.status === "skipped" || step.status === "superseded" || step.status === "canceled" - ? `Step ${step.stepKey} was skipped.` - : `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 && latestAttempt) { - try { - const sanitizedKey = step.stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - 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(); - } - } catch { - // Non-critical — fall back to digest summary - } - } - - // If we have a step output file, extract the Summary section to enrich the outcome - let enrichedSummary: string | null = null; - if (stepOutputContent) { - const summaryMatch = stepOutputContent.match(/^## Summary\s*\n([\s\S]*?)(?=\n## |\n*$)/m); - if (summaryMatch?.[1]?.trim()) { - enrichedSummary = summaryMatch[1].trim(); - } - } - - const summarySource = enrichedSummary || digest?.summary?.trim() || fallbackSummary; - const summary = summarySource.length > 0 ? summarySource.slice(0, 320) : `Step ${step.stepKey} update.`; - const testsRun = digest?.testsRun - ? { - passed: Math.max(0, Math.floor(Number(digest.testsRun.passed ?? 0) || 0)), - failed: Math.max(0, Math.floor(Number(digest.testsRun.failed ?? 0) || 0)), - skipped: Math.max(0, Math.floor(Number(digest.testsRun.skipped ?? 0) || 0)), - } - : undefined; - - return { - stepKey: step.stepKey, - stepName: step.title, - phase: resolveMissionStateStepPhase(step), - status: toMissionStateStepStatus(step.status), - summary, - filesChanged: digest?.filesChanged ?? [], - ...(testsRun ? { testsRun } : {}), - warnings: digest?.warnings ?? [], - completedAt: - MISSION_STATE_TERMINAL_STEP_STATUSES.has(step.status) - ? step.completedAt ?? latestAttempt?.completedAt ?? nowIso() - : null, - }; - }; - - const resolveProjectIdForMission = (missionId?: string | null): string | null => { - const normalizedMissionId = toOptionalString(missionId); - if (normalizedMissionId) { - const missionProjectId = - toOptionalString( - db.get<{ project_id: string | null }>( - `select project_id from missions where id = ? limit 1`, - [normalizedMissionId], - )?.project_id, - ); - if (missionProjectId) return missionProjectId; - } - - return toOptionalString( - db.get<{ id: string | null }>( - ` - select id - from projects - order by datetime(last_opened_at) desc, datetime(created_at) desc, id asc - limit 1 - `, - )?.id, - ); - }; - - /** - * Resolve the primary lane ID. Returns null if lane service is unavailable. - */ - const resolvePrimaryLaneId = async (missionId?: string | null): Promise => { - const effectiveProjectId = resolveProjectIdForMission(missionId); - if (!effectiveProjectId) return null; - if (laneService && typeof laneService.list === "function") { - const lanes = await laneService.list({ includeArchived: false }); - const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0] ?? null; - return primary?.id?.trim() || null; - } - const laneRow = db.get<{ id: string | null }>( - ` - select id - from lanes - where project_id = ? - and archived_at is null - order by case when lane_type = 'primary' then 0 else 1 end, created_at asc, id asc - limit 1 - `, - [effectiveProjectId] - ); - return toOptionalString(laneRow?.id); - }; - - const resolveMissionBaseLaneId = async (missionId?: string | null): Promise => { - const normalizedMissionId = toOptionalString(missionId); - if (normalizedMissionId) { - const selectedLaneId = toOptionalString(missionService.get(normalizedMissionId)?.laneId); - if (selectedLaneId) return selectedLaneId; - } - return await resolvePrimaryLaneId(missionId); - }; - - const resolveLaneBranchName = async (laneId?: string | null): Promise => { - const normalizedLaneId = toOptionalString(laneId); - if (!normalizedLaneId) return null; - if (laneService && typeof laneService.list === "function") { - const lanes = await laneService.list({ includeArchived: false }); - const lane = lanes.find((entry) => entry.id === normalizedLaneId) ?? null; - if (lane) { - return normalizeBranchName(lane.branchRef || lane.baseRef || "") || null; - } - } - const laneRow = db.get<{ branch_ref: string | null; base_ref: string | null }>( - ` - select branch_ref, base_ref - from lanes - where id = ? - limit 1 - `, - [normalizedLaneId] - ); - return normalizeBranchName(laneRow?.branch_ref ?? laneRow?.base_ref ?? "") || null; - }; - - const resolveMissionBaseBranch = async (missionId?: string | null): Promise => { - const baseLaneId = await resolveMissionBaseLaneId(missionId); - return await resolveLaneBranchName(baseLaneId); - }; - - /** - * Create a lane branching from the mission's selected base lane. - * Falls back to primary when the mission has no explicit base lane. - * Used for both initial mission lane creation and dynamic provisioning. - * Returns { laneId, name } or null if unavailable. - */ - const createLaneFromBase = async ( - name: string, - opts: { description?: string; folder?: string; missionId: string; laneRole?: "mission_root" | "worker" | "integration" | "result" | null }, - ): Promise<{ laneId: string; name: string } | null> => { - if (!laneService) return null; - const baseLaneId = await resolveMissionBaseLaneId(opts.missionId); - if (!baseLaneId) { - logger.warn("ai_orchestrator.lane_create_skip", { missionId: opts.missionId, reason: "no_base_lane" }); - return null; - } - const child = await laneService.createChild({ - parentLaneId: baseLaneId, - name, - description: opts.description, - folder: opts.folder, - missionId: opts.missionId, - laneRole: opts.laneRole ?? "worker", - }); - logger.info("ai_orchestrator.lane_created", { - missionId: opts.missionId, - laneId: child.id, - name, - baseLaneId, - }); - return { laneId: child.id, name: child.name }; - }; - - /** - * Create a dedicated mission lane. Returns the lane ID or null on failure. - */ - const createMissionLane = async (missionId: string, missionTitle: string): Promise => { - try { - const laneName = missionTitle.trim().length > 0 ? missionTitle.trim() : `Mission ${missionId.slice(0, 6)}`; - const result = await createLaneFromBase( - laneName, - { description: `Mission lane for ${missionTitle}`, folder: `Mission: ${missionTitle}`, missionId, laneRole: "mission_root" }, - ); - if (result?.laneId) { - missionService.setMissionLane({ missionId, laneId: result.laneId }); - } - return result?.laneId ?? null; - } catch (error) { - logger.warn("ai_orchestrator.mission_lane_creation_failed", { - missionId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }; - - const readMissionLaneIdFromRunMetadata = (metadata: Record): string | null => { - const directLaneId = toOptionalString(metadata.missionLaneId); - if (directLaneId) return directLaneId; - - const coordinatorMeta = isRecord(metadata.coordinator) ? metadata.coordinator : null; - const coordinatorLaneId = coordinatorMeta ? toOptionalString(coordinatorMeta.missionLaneId) : null; - if (coordinatorLaneId) return coordinatorLaneId; - - const teamRuntimeMeta = isRecord(metadata.teamRuntime) ? metadata.teamRuntime : null; - return teamRuntimeMeta ? toOptionalString(teamRuntimeMeta.missionLaneId) : null; - }; - - const persistMissionLaneIdForRun = (runId: string, laneId: string): void => { - const normalizedRunId = toOptionalString(runId); - const normalizedLaneId = toOptionalString(laneId); - if (!normalizedRunId || !normalizedLaneId) return; - updateRunMetadata(normalizedRunId, (metadata) => { - metadata.missionLaneId = normalizedLaneId; - const coordinatorMeta = isRecord(metadata.coordinator) ? { ...metadata.coordinator } : {}; - coordinatorMeta.missionLaneId = normalizedLaneId; - metadata.coordinator = coordinatorMeta; - const teamRuntimeMeta = isRecord(metadata.teamRuntime) ? { ...metadata.teamRuntime } : {}; - teamRuntimeMeta.missionLaneId = normalizedLaneId; - metadata.teamRuntime = teamRuntimeMeta; - }); - }; - - const resolvePersistedMissionLaneIdForRun = (runId: string): string | null => { - const normalizedRunId = toOptionalString(runId); - if (!normalizedRunId) return null; - - const metadataLaneId = readMissionLaneIdFromRunMetadata(getRunMetadata(normalizedRunId)); - if (metadataLaneId) return metadataLaneId; - - const derivedLaneRow = db.get<{ lane_id: string | null }>( - ` - select lane_id - from orchestrator_steps - where run_id = ? - and lane_id is not null - group by lane_id - order by count(*) desc, min(step_index) asc, lane_id asc - limit 1 - `, - [normalizedRunId] - ); - return toOptionalString(derivedLaneRow?.lane_id); - }; - - const ensureMissionLaneForRun = async (args: { - runId: string; - missionId: string; - missionTitle: string; - createIfMissing?: boolean; - }): Promise => { - const persistedLaneId = resolvePersistedMissionLaneIdForRun(args.runId); - if (persistedLaneId) { - persistMissionLaneIdForRun(args.runId, persistedLaneId); - missionService.setMissionLane({ missionId: args.missionId, laneId: persistedLaneId }); - await setLaneMissionOwnership(persistedLaneId, args.missionId, "mission_root"); - return persistedLaneId; - } - if (args.createIfMissing === false) return null; - const createdLaneId = await createMissionLane(args.missionId, args.missionTitle); - if (createdLaneId) { - persistMissionLaneIdForRun(args.runId, createdLaneId); - await setLaneMissionOwnership(createdLaneId, args.missionId, "mission_root"); - } - return createdLaneId; - }; - - const archiveMissionIntermediateLanes = async (missionId: string, resultLaneId: string): Promise => { - const missionOwned = db.all<{ id: string }>( - ` - select id - from lanes - where mission_id = ? - and archived_at is null - and id <> ? - `, - [missionId, resultLaneId], - ); - for (const lane of missionOwned) { - try { - if (typeof laneService?.archive === "function") { - await laneService!.archive({ laneId: lane.id }); - } else { - db.run( - ` - update lanes - set status = 'archived', - archived_at = coalesce(archived_at, ?) - where id = ? - `, - [nowIso(), lane.id], - ); - } - } catch (error) { - logger.warn("ai_orchestrator.archive_intermediate_mission_lane_failed", { - missionId, - laneId: lane.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } - }; - - const setLaneMissionOwnership = async (laneId: string, missionId: string, laneRole: "mission_root" | "worker" | "integration" | "result"): Promise => { - if (laneService?.setMissionOwnership) { - await laneService.setMissionOwnership({ laneId, missionId, laneRole }); - } else { - db.run( - `update lanes set mission_id = ?, lane_role = ? where id = ?`, - [missionId, laneRole, laneId], - ); - } - }; - - const finalizeMissionToResultLane = async (args: { - mission: MissionDetail; - runId: string; - missionBaseBranch: string; - }): Promise<{ resultLaneId: string; summary: string; detail: string; warnings: string[] }> => { - const missionLaneId = resolvePersistedMissionLaneIdForRun(args.runId) ?? toOptionalString(args.mission.missionLaneId); - const fallbackGraph = orchestratorService.getRunGraph({ runId: args.runId, timelineLimit: 0 }); - const activeMissionLanes = db.all<{ id: string }>( - ` - select id - from lanes - where mission_id = ? - and archived_at is null - and coalesce(lane_role, '') <> 'result' - `, - [args.mission.id], - ); - const stepLaneIds = [...new Set( - fallbackGraph.steps - .map((step) => toOptionalString(step.laneId)) - .filter((laneId): laneId is string => laneId != null) - )]; - let sourceLaneIds = [...new Set([ - ...activeMissionLanes.map((lane) => lane.id), - ...stepLaneIds, - ...(missionLaneId ? [missionLaneId] : []), - ].filter(Boolean))] as string[]; - if (sourceLaneIds.length === 0) { - const missionBaseLaneId = toOptionalString(args.mission.laneId); - if (missionBaseLaneId) { - sourceLaneIds = [missionBaseLaneId]; - } - } - if (sourceLaneIds.length === 0) { - throw new Error("No mission-owned lanes were available to assemble a result lane."); - } - - const missionLaneHasConsolidatedResult = - !!missionLaneId - && fallbackGraph.steps.some((step) => { - if (step.laneId !== missionLaneId || step.status !== "succeeded") return false; - const meta = isRecord(step.metadata) ? step.metadata : {}; - const phaseKey = typeof meta.phaseKey === "string" ? meta.phaseKey.trim().toLowerCase() : ""; - const taskType = typeof meta.taskType === "string" ? meta.taskType.trim().toLowerCase() : ""; - const stepType = typeof meta.stepType === "string" ? meta.stepType.trim().toLowerCase() : ""; - const title = step.title.trim().toLowerCase(); - return phaseKey === "integration" - || phaseKey === "closeout" - || taskType === "integration" - || taskType === "result_lane_consolidation" - || stepType === "integration" - || title.includes("result-lane consolidation") - || title.includes("result lane consolidation"); - }); - if (missionLaneId && missionLaneHasConsolidatedResult) { - missionService.setResultLane({ missionId: args.mission.id, laneId: missionLaneId }); - await setLaneMissionOwnership(missionLaneId, args.mission.id, "result"); - await archiveMissionIntermediateLanes(args.mission.id, missionLaneId); - return { - resultLaneId: missionLaneId, - summary: "Execution completed. The mission result lane is ready.", - detail: `Result lane ${missionLaneId} already contains the consolidated mission output.`, - warnings: [], - }; - } - - if (sourceLaneIds.length === 1) { - const resultLaneId = sourceLaneIds[0]!; - missionService.setResultLane({ missionId: args.mission.id, laneId: resultLaneId }); - await setLaneMissionOwnership(resultLaneId, args.mission.id, "result"); - await archiveMissionIntermediateLanes(args.mission.id, resultLaneId); - return { - resultLaneId, - summary: "Execution completed. The mission result lane is ready.", - detail: `Result lane ${resultLaneId} now contains the mission output.`, - warnings: [], - }; - } - - if (!prService) { - throw new Error("Mission produced multiple lanes, but result-lane assembly is unavailable."); - } - - const slug = args.mission.title - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || `mission-${args.mission.id.slice(0, 8)}`; - const assembly = await prService.createIntegrationLane({ - sourceLaneIds, - integrationLaneName: `mission/${slug}-result`, - baseBranch: args.missionBaseBranch, - description: `Result lane for mission "${args.mission.title}"`, - missionId: args.mission.id, - laneRole: "result", - }); - - const resultLaneId = assembly.integrationLane.id; - missionService.setResultLane({ missionId: args.mission.id, laneId: resultLaneId }); - await setLaneMissionOwnership(resultLaneId, args.mission.id, "result"); - - const failedMerges = assembly.mergeResults.filter((entry) => !entry.success); - if (failedMerges.length > 0) { - const detail = failedMerges - .map((entry) => `${entry.laneId}: ${entry.error ?? "merge failed"}`) - .join("\n"); - return { - resultLaneId, - summary: "Result lane assembly is blocked by merge conflicts.", - detail, - warnings: failedMerges.map((entry) => `${entry.laneId}: ${entry.error ?? "merge failed"}`), - }; - } - - await archiveMissionIntermediateLanes(args.mission.id, resultLaneId); - return { - resultLaneId, - summary: "Execution completed. The mission result lane is ready.", - detail: `Result lane ${assembly.integrationLane.name} contains the merged mission output.`, - warnings: [], - }; - }; - - const recordRuntimeEvent = (args: { - runId: string; - stepId?: string | null; - attemptId?: string | null; - sessionId?: string | null; - eventType: - | "progress" - | "heartbeat" - | "question" - | "blocked" - | "done" - | "retry_scheduled" - | "retry_exhausted" - | "claim_conflict" - | "session_ended" - | "intervention_opened" - | "intervention_resolved" - | "coordinator_steering" - | "coordinator_broadcast" - | "coordinator_skip" - | "coordinator_add_step" - | "coordinator_pause" - | "coordinator_parallelize" - | "coordinator_consolidate" - | "coordinator_shutdown" - | "worker_status_report" - | "worker_result_report" - | "worker_message" - | "plan_revised" - | "lane_transfer" - | "validation_report" - | "validation_contract_unfulfilled" - | "validation_self_check_reminder" - | "validation_gate_blocked" - | "tool_profiles_updated"; - eventKey?: string | null; - occurredAt?: string | null; - payload?: Record | null; - }) => { - try { - orchestratorService.appendRuntimeEvent(args); - - if (args.eventType === "validation_report") { - const payload = isRecord(args.payload) ? args.payload : {}; - const scope = isRecord(payload.scope) ? payload.scope : {}; - const verdict = - payload.verdict === "pass" || payload.verdict === "fail" - ? payload.verdict - : null; - const findings = Array.isArray(payload.findings) - ? payload.findings - .map((entry) => { - if (!isRecord(entry)) return ""; - const severity = typeof entry.severity === "string" ? entry.severity.trim() : ""; - const code = typeof entry.code === "string" ? entry.code.trim() : ""; - const message = typeof entry.message === "string" ? entry.message.trim() : ""; - const composed = [severity, code, message].filter((part) => part.length > 0).join(" "); - return composed.trim(); - }) - .filter((entry) => entry.length > 0) - : []; - const graph = (() => { - try { - return orchestratorService.getRunGraph({ runId: args.runId, timelineLimit: 0 }); - } catch { - return null; - } - })(); - const targetStep = - graph?.steps.find((step) => step.id === args.stepId) - ?? (() => { - const key = typeof scope.stepKey === "string" ? scope.stepKey.trim() : ""; - if (!key.length || !graph) return null; - return graph.steps.find((step) => step.stepKey === key) ?? null; - })(); - const stepKey = - targetStep?.stepKey - ?? (typeof scope.stepKey === "string" ? scope.stepKey.trim() : ""); - if (stepKey.length > 0) { - const stepMeta = isRecord(targetStep?.metadata) ? targetStep.metadata : {}; - const phase = - (typeof stepMeta.phaseName === "string" && stepMeta.phaseName.trim().length > 0 - ? stepMeta.phaseName.trim() - : typeof stepMeta.phaseKey === "string" && stepMeta.phaseKey.trim().length > 0 - ? stepMeta.phaseKey.trim() - : "unknown"); - updateMissionStateDoc(args.runId, { - updateStepOutcome: { - stepKey, - updates: { - ...(targetStep ? { stepName: targetStep.title, phase } : {}), - validation: { - verdict, - findings, - }, - }, - }, - }, { graph }); - } - } else if (args.eventType === "validation_contract_unfulfilled") { - const payload = isRecord(args.payload) ? args.payload : {}; - const stepKey = typeof payload.stepKey === "string" ? payload.stepKey.trim() : ""; - const detail = typeof payload.detail === "string" ? payload.detail.trim() : ""; - const description = - detail.length > 0 - ? detail - : "A required validation contract was not fulfilled for a completed step."; - updateMissionStateDoc(args.runId, { - addIssue: { - id: `validation-contract-${randomUUID()}`, - severity: "high", - description, - affectedSteps: stepKey.length > 0 ? [stepKey] : [], - status: "open", - }, - }); - } else if (args.eventType === "validation_gate_blocked") { - const payload = isRecord(args.payload) ? args.payload : {}; - const detail = typeof payload.reason === "string" ? payload.reason.trim() : ""; - const stepKey = typeof payload.stepKey === "string" ? payload.stepKey.trim() : ""; - updateMissionStateDoc(args.runId, { - addIssue: { - id: `validation-gate-blocked-${randomUUID()}`, - severity: "medium", - description: detail.length > 0 ? detail : "Validation gate blocked worker creation until required upstream validation passes.", - affectedSteps: stepKey.length > 0 ? [stepKey] : [], - status: "open", - }, - }); - } else if (args.eventType === "plan_revised") { - const payload = isRecord(args.payload) ? args.payload : {}; - const reason = typeof payload.reason === "string" ? payload.reason.trim() : "Coordinator revised the plan."; - const replaced = Array.isArray(payload.replacedStepKeys) - ? payload.replacedStepKeys.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - const added = Array.isArray(payload.newStepKeys) - ? payload.newStepKeys.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - const contextParts = [ - replaced.length > 0 ? `Replaced: ${replaced.join(", ")}` : "", - added.length > 0 ? `Added: ${added.join(", ")}` : "", - ].filter((entry) => entry.length > 0); - updateMissionStateDoc(args.runId, { - addDecision: { - timestamp: nowIso(), - decision: "Plan revised", - rationale: reason, - context: contextParts.join(" | ") || "Plan changed based on coordinator runtime assessment.", - }, - }); - } else if (args.eventType === "intervention_opened" || args.eventType === "intervention_resolved") { - const missionId = getMissionIdForRun(args.runId); - if (missionId) { - updateMissionStateDoc(args.runId, { - pendingInterventions: pendingInterventionsForMission(missionId), - }); - } - } - } catch (error) { - logger.debug("ai_orchestrator.runtime_event_append_failed", { - runId: args.runId, - stepId: args.stepId ?? null, - attemptId: args.attemptId ?? null, - sessionId: args.sessionId ?? null, - eventType: args.eventType, - error: error instanceof Error ? error.message : String(error) - }); - } - }; - - const hookDispatchDeps: HookDispatchDeps = { recordRuntimeEvent }; - - const dispatchOrchestratorHook = (hookArgs: Parameters[1]) => - dispatchOrchestratorHookCtx(ctx, hookArgs, hookDispatchDeps); - - const maybeDispatchTeammateIdleHook = (idleArgs: Parameters[1]) => - maybeDispatchTeammateIdleHookCtx(ctx, idleArgs, hookDispatchDeps); - - // Delegated to missionLifecycle.ts - const resolveActivePhaseSettings = (missionId: string) => resolveActivePhaseSettingsCtx(ctx, missionId); - const resolveActiveRuntimeProfile = (missionId: string) => resolveActiveRuntimeProfileCtx(ctx, missionId); - - const getSteeringContext = (missionId: string): string => { - const directives = activeSteeringDirectives.get(missionId) ?? loadSteeringDirectivesFromMetadata(missionId); - if (!directives?.length) return ""; - const recent = directives.slice(-MAX_STEERING_CONTEXT_DIRECTIVES); - const rendered = [ - "", - "Active user steering directives (apply these to your decisions):", - ...recent.map((d, i) => { - const compactDirective = clipTextForContext(d.directive.replace(/\s+/g, " ").trim(), MAX_CHAT_LINE_CHARS); - return ` ${i + 1}. [${d.priority}] ${compactDirective}${d.targetStepKey ? ` (target: ${d.targetStepKey})` : ""}`; - }), - "" - ].join("\n"); - return clipTextForContext(rendered, MAX_STEERING_CONTEXT_CHARS); - }; - - const projectSteeringDirectiveToActiveSteps = (directive: UserSteeringDirective): number => { - const activeRuns = orchestratorService - .listRuns({ missionId: directive.missionId, limit: 200 }) - .filter((run) => run.status === "active" || run.status === "bootstrapping" || run.status === "queued" || run.status === "paused"); - if (!activeRuns.length) return 0; - - const appliedAt = nowIso(); - const targetStepKey = directive.targetStepKey?.trim() ?? ""; - let updated = 0; - for (const run of activeRuns) { - let graph: OrchestratorRunGraph | null = null; - try { - graph = orchestratorService.getRunGraph({ runId: run.id, timelineLimit: 0 }); - } catch (error) { - logger.debug("ai_orchestrator.steer_projection_graph_failed", { - missionId: directive.missionId, - runId: run.id, - error: error instanceof Error ? error.message : String(error) - }); - continue; - } - if (!graph) continue; - - for (const step of graph.steps) { - if (step.status !== "pending" && step.status !== "ready" && step.status !== "running" && step.status !== "blocked") continue; - if (targetStepKey.length > 0 && step.stepKey !== targetStepKey) continue; - const nextMetadata = isRecord(step.metadata) ? { ...step.metadata } : {}; - const existing = Array.isArray(nextMetadata.steeringDirectives) ? nextMetadata.steeringDirectives as unknown[] : []; - const normalizedExisting = existing - .map((entry) => (isRecord(entry) ? entry : null)) - .filter((entry): entry is Record => !!entry) - .map((entry) => { - const text = typeof entry.directive === "string" ? entry.directive.trim() : ""; - if (!text.length) return null; - const priority = entry.priority === "instruction" || entry.priority === "override" ? entry.priority : "suggestion"; - return { - directive: text, - priority, - targetStepKey: typeof entry.targetStepKey === "string" ? entry.targetStepKey : null, - appliedAt: typeof entry.appliedAt === "string" ? entry.appliedAt : null - }; - }) - .filter((entry): entry is { directive: string; priority: "suggestion" | "instruction" | "override"; targetStepKey: string | null; appliedAt: string | null } => !!entry); - normalizedExisting.push({ - directive: directive.directive, - priority: directive.priority, - targetStepKey: directive.targetStepKey ?? null, - appliedAt - }); - nextMetadata.steeringDirectives = normalizedExisting.slice(-MAX_STEERING_DIRECTIVES_PER_STEP); - db.run( - ` - update orchestrator_steps - set metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(nextMetadata), appliedAt, step.id, run.id, run.projectId] - ); - updated += 1; - } - } - return updated; - }; - - // Delegate to workerTracking module via ctx - const getWorkerStates = (args: { runId: string }) => getWorkerStatesCtx(ctx, args); - const upsertWorkerState = ( - attemptId: string, - update: { - stepId: string; - runId: string; - sessionId?: string | null; - executorKind: OrchestratorExecutorKind; - state: OrchestratorWorkerStatus; - outcomeTags?: string[]; - completedAt?: string | null; - } - ) => upsertWorkerStateCtx(ctx, attemptId, update); - - // Delegate to recoveryService module via ctx - const ensureAttemptRuntimeTracker = (attemptId: string) => ensureAttemptRuntimeTrackerCtx(ctx, attemptId); - - // Delegated to recoveryService.ts - const deletePersistedAttemptRuntimeState = (attemptId: string) => deletePersistedAttemptRuntimeStateCtx(ctx, attemptId); - const persistAttemptRuntimeState = (args: Parameters[1]) => persistAttemptRuntimeStateCtx(ctx, args); - - // Delegated to recoveryService.ts - const hydratePersistedAttemptRuntimeState = () => hydratePersistedAttemptRuntimeStateCtx(ctx); - - const hydrateRuntimeSignalsFromEventBus = () => { - let events: ReturnType; - try { - events = orchestratorService.listRuntimeEvents({ - eventTypes: ["progress", "heartbeat", "question", "session_ended"], - limit: STARTUP_RUNTIME_EVENT_HYDRATION_LIMIT - }); - } catch { - return; - } - if (!events.length) return; - - const isSyntheticSweepHeartbeat = (event: (typeof events)[number]): boolean => { - if (event.eventType !== "heartbeat") return false; - const payload = isRecord(event.payload) ? event.payload : null; - return payload?.source === "health_sweep"; - }; - - const latestBySession = new Map(); - for (const event of events) { - const sessionId = typeof event.sessionId === "string" ? event.sessionId.trim() : ""; - if (!sessionId.length || latestBySession.has(sessionId)) continue; - if (isSyntheticSweepHeartbeat(event)) continue; - latestBySession.set(sessionId, event); - } - - for (const event of latestBySession.values()) { - const sessionId = event.sessionId?.trim() ?? ""; - if (!sessionId.length) continue; - const runtimeState: TerminalRuntimeState = - event.eventType === "session_ended" - ? "exited" - : event.eventType === "question" - ? "waiting-input" - : "running"; - const payload = isRecord(event.payload) ? event.payload : null; - const preview = - payload && typeof payload.preview === "string" && payload.preview.trim().length > 0 - ? payload.preview.trim() - : null; - const existing = sessionRuntimeSignals.get(sessionId); - const existingMs = existing ? Date.parse(existing.at) : Number.NaN; - const eventMs = Date.parse(event.occurredAt); - if (Number.isFinite(existingMs) && Number.isFinite(eventMs) && existingMs > eventMs) continue; - sessionRuntimeSignals.set(sessionId, { - laneId: null, - sessionId, - runtimeState, - lastOutputPreview: preview, - at: event.occurredAt - }); - } - - for (const event of events) { - const attemptId = typeof event.attemptId === "string" ? event.attemptId.trim() : ""; - if (!attemptId.length) continue; - const tracker = ensureAttemptRuntimeTracker(attemptId); - const eventMs = Date.parse(event.occurredAt); - if (!Number.isFinite(eventMs)) continue; - if (event.eventType === "heartbeat") { - tracker.lastEventHeartbeatAtMs = Math.max(tracker.lastEventHeartbeatAtMs, Math.floor(eventMs)); - } - if (event.eventType === "question") { - tracker.lastWaitingInterventionAtMs = Math.max(tracker.lastWaitingInterventionAtMs, Math.floor(eventMs)); - const questionLink = event.questionLink ?? parseQuestionLink(event.payload); - if (questionLink) { - tracker.lastQuestionThreadId = questionLink.threadId; - tracker.lastQuestionMessageId = questionLink.messageId; - } - } else if (event.eventType === "progress") { - const payload = isRecord(event.payload) ? event.payload : null; - const transition = typeof payload?.transition === "string" ? payload.transition.trim() : ""; - if (transition === "question_answered_resume") { - const questionLink = event.questionLink ?? parseQuestionLink(event.payload); - if (questionLink) { - tracker.lastQuestionThreadId = questionLink.threadId; - tracker.lastQuestionMessageId = questionLink.messageId; - } - } - } - tracker.lastPersistedAtMs = Math.max(tracker.lastPersistedAtMs, Math.floor(eventMs)); - } - }; - - const replayOpenQuestionsFromEventBus = () => { - const runs = orchestratorService - .listRuns({ limit: 1_000 }) - .filter((run) => run.status === "active" || run.status === "bootstrapping" || run.status === "queued" || run.status === "paused"); - for (const run of runs) { - let graph: OrchestratorRunGraph; - try { - graph = orchestratorService.getRunGraph({ runId: run.id, timelineLimit: 0 }); - } catch { - continue; - } - const mission = missionService.get(graph.run.missionId); - if (!mission) continue; - let events: ReturnType; - try { - events = orchestratorService.listRuntimeEvents({ - runId: run.id, - eventTypes: ["question", "intervention_resolved", "progress"], - limit: STARTUP_OPEN_QUESTION_REPLAY_LIMIT - }); - } catch { - continue; - } - if (!events.length) continue; - - const stepById = new Map(graph.steps.map((step) => [step.id, step] as const)); - const attemptById = new Map(graph.attempts.map((attempt) => [attempt.id, attempt] as const)); - const latestByAttempt = new Map(); - for (const event of events) { - const attemptId = typeof event.attemptId === "string" ? event.attemptId.trim() : ""; - if (!attemptId.length || latestByAttempt.has(attemptId)) continue; - if (event.eventType === "progress") { - const payload = isRecord(event.payload) ? event.payload : null; - const transition = typeof payload?.transition === "string" ? payload.transition.trim() : ""; - if (transition !== "question_answered_resume") continue; - } - latestByAttempt.set(attemptId, event); - } - - for (const [attemptId, event] of latestByAttempt.entries()) { - if (event.eventType !== "question") continue; - const attempt = attemptById.get(attemptId); - if (!attempt || attempt.status !== "running") continue; - const step = stepById.get(attempt.stepId); - if (!step) continue; - const payload = isRecord(event.payload) ? event.payload : null; - const questionLink = - parseQuestionLink(payload) ?? - buildQuestionThreadLink({ - attemptId, - occurredAt: event.occurredAt, - preview: typeof payload?.preview === "string" ? payload.preview : null - }); - const tracker = ensureAttemptRuntimeTracker(attemptId); - tracker.lastQuestionThreadId = questionLink.threadId; - tracker.lastQuestionMessageId = questionLink.messageId; - upsertWorkerState(attemptId, { - runId: run.id, - stepId: step.id, - sessionId: event.sessionId ?? attempt.executorSessionId ?? null, - executorKind: attempt.executorKind, - state: "waiting_input" - }); - ensureManualInputIntervention({ - missionId: graph.run.missionId, - runId: run.id, - stepId: step.id, - stepKey: step.stepKey, - stepTitle: stepTitleForMessage(step), - laneId: step.laneId ?? null, - attemptId, - sessionId: event.sessionId ?? attempt.executorSessionId ?? "session-unknown", - preview: typeof payload?.preview === "string" ? payload.preview : null, - questionLink, - reason: "health_sweep" - }); - } - } - }; - - const updateAttemptStagnationTracker = (attemptId: string, preview: string | null) => - updateAttemptStagnationTrackerCtx(ctx, attemptId, preview); - - - const ensureManualInputIntervention = (args: { - missionId: string; - runId: string; - stepId: string; - stepKey: string; - stepTitle: string; - laneId: string | null; - attemptId: string; - sessionId: string; - preview: string | null; - questionLink: OrchestratorRuntimeQuestionLink; - reason: "runtime_signal" | "health_sweep"; - }) => { - const mission = missionService.get(args.missionId); - if (!mission) return; - const existing = mission.interventions.find((entry) => { - if (entry.status !== "open" || entry.interventionType !== "manual_input") return false; - if (!isRecord(entry.metadata)) return false; - return entry.metadata.attemptId === args.attemptId; - }); - if (existing) return existing; - const tracker = ensureAttemptRuntimeTracker(args.attemptId); - const now = Date.now(); - if ( - args.reason !== "health_sweep" && - now - tracker.lastWaitingInterventionAtMs < WORKER_WAITING_INPUT_INTERVENTION_COOLDOWN_MS - ) { - return null; - } - tracker.lastWaitingInterventionAtMs = now; - const preview = typeof args.preview === "string" && args.preview.trim().length - ? args.preview.trim() - : "Worker requested operator guidance."; - const intervention = missionService.addIntervention({ - missionId: args.missionId, - interventionType: "manual_input", - title: `Worker waiting for input: ${args.stepTitle}`, - body: `Step ${args.stepKey} is waiting for operator input. Latest signal: ${preview}`, - requestedAction: "Provide steering instruction so execution can continue.", - laneId: args.laneId, - pauseMission: false, - metadata: { - attemptId: args.attemptId, - stepId: args.stepId, - runId: args.runId, - sessionId: args.sessionId, - canProceedWithoutAnswer: false, - threadId: args.questionLink.threadId, - messageId: args.questionLink.messageId, - replyTo: args.questionLink.replyTo, - reason: args.reason - } - }); - recordRuntimeEvent({ - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - sessionId: args.sessionId, - eventType: "intervention_opened", - eventKey: `intervention_opened:${intervention.id}`, - payload: { - interventionId: intervention.id, - interventionType: intervention.interventionType, - reason: args.reason, - threadId: args.questionLink.threadId, - messageId: args.questionLink.messageId, - replyTo: args.questionLink.replyTo, - preview - } - }); - emitOrchestratorMessage( - args.missionId, - `Step "${args.stepTitle}" is waiting for input. I opened an intervention so you can respond without babysitting terminals.`, - args.stepKey, - { - interventionId: intervention.id, - attemptId: args.attemptId, - sessionId: args.sessionId - } - ); - return intervention; - }; - - const resolveStepTimeoutMs = (args: { - runId: string; - missionId: string; - step: OrchestratorRunGraph["steps"][number]; - }): number => { - const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {}; - const planStep = isRecord(stepMeta.planStep) ? stepMeta.planStep : null; - const aiStepTimeout = Number(stepMeta.aiTimeoutMs); - const explicitStepTimeout = Number(stepMeta.timeoutMs); - const planStepTimeout = Number(planStep?.timeoutMs ?? NaN); - const runtimeProfile = runRuntimeProfiles.get(args.runId) ?? resolveActiveRuntimeProfile(args.missionId); - const fallback = runtimeProfile.execution.stepTimeoutMs; - const raw = - Number.isFinite(aiStepTimeout) && aiStepTimeout > 0 - ? aiStepTimeout - : Number.isFinite(explicitStepTimeout) && explicitStepTimeout > 0 - ? explicitStepTimeout - : Number.isFinite(planStepTimeout) && planStepTimeout > 0 - ? planStepTimeout - : fallback; - const floorMs = Math.max(30_000, Math.floor(raw)); - const capMs = resolveMissionDecisionTimeoutCapMs(args.missionId); - return Math.max(30_000, Math.min(floorMs, capMs)); - }; - - // Deterministic timeout/retry/priority/stagnation functions removed — the - // coordinator AI handles these decisions through its persistent conversation. - - - const getTrackedSessionState = (sessionId: string) => getTrackedSessionStateCtx(ctx, sessionId); - - const pruneSessionRuntimeSignals = () => pruneSessionRuntimeSignalsCtx(ctx); - - const listRunningAttemptsForSession = (sessionId: string) => listRunningAttemptsForSessionCtx(ctx, sessionId); - - const processSessionRuntimeSignal = async (signal: SessionRuntimeSignal): Promise => { - const sessionId = signal.sessionId.trim(); - if (!sessionId.length) return; - - const waitingForInput = - signal.runtimeState === "waiting-input" || detectWaitingInputSignal(signal.lastOutputPreview); - const runtimeWorkerState = workerStateFromRuntimeSignal({ - runtimeState: signal.runtimeState, - waitingForInput - }); - const isTerminalSignal = signal.runtimeState === "exited" || signal.runtimeState === "killed"; - const attempts = listRunningAttemptsForSession(sessionId); - if (!attempts.length) return; - - const graphByRunId = new Map(); - const syncedRunIds = new Set(); - - for (const attempt of attempts) { - const tracker = ensureAttemptRuntimeTracker(attempt.attemptId); - const nowMs = Date.now(); - const ownerId = resolveAttemptOwnerIdFromRows(attempt.attemptMetadataJson, attempt.runMetadataJson); - if (nowMs - tracker.lastEventHeartbeatAtMs >= WORKER_EVENT_HEARTBEAT_INTERVAL_MS) { - tracker.lastEventHeartbeatAtMs = nowMs; - try { - orchestratorService.heartbeatClaims({ - attemptId: attempt.attemptId, - ownerId - }); - recordRuntimeEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.attemptId, - sessionId, - eventType: "heartbeat", - eventKey: `heartbeat:${attempt.attemptId}:${Math.floor(nowMs / WORKER_EVENT_HEARTBEAT_INTERVAL_MS)}`, - occurredAt: signal.at, - payload: { - runtimeState: signal.runtimeState, - ownerId - } - }); - } catch (error) { - logger.debug("ai_orchestrator.runtime_signal_heartbeat_failed", { - attemptId: attempt.attemptId, - runId: attempt.runId, - ownerId, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - let graph = graphByRunId.get(attempt.runId); - if (!graph) { - try { - graph = orchestratorService.getRunGraph({ runId: attempt.runId, timelineLimit: 0 }); - graphByRunId.set(attempt.runId, graph); - } catch (error) { - logger.debug("ai_orchestrator.runtime_signal_graph_lookup_failed", { - sessionId, - runId: attempt.runId, - attemptId: attempt.attemptId, - error: error instanceof Error ? error.message : String(error) - }); - continue; - } - } - - const step = graph.steps.find((entry) => entry.id === attempt.stepId); - if (!step) continue; - - updateAttemptStagnationTracker(attempt.attemptId, signal.lastOutputPreview); - const previousWorkerState = workerStates.get(attempt.attemptId)?.state ?? null; - const attemptMetadata = parseJsonRecord(attempt.attemptMetadataJson); - const workerSessionKind = typeof attemptMetadata?.workerSessionKind === "string" - ? attemptMetadata.workerSessionKind.trim() - : ""; - const effectiveRuntimeWorkerState = - workerSessionKind === "managed_chat" - && !structuredChatSessions.has(sessionId) - && runtimeWorkerState === "working" - ? "initializing" - : runtimeWorkerState; - upsertWorkerState(attempt.attemptId, { - runId: attempt.runId, - stepId: attempt.stepId, - sessionId, - executorKind: attempt.executorKind, - state: effectiveRuntimeWorkerState - }); - if (attempt.executorKind !== "manual") { - maybeDispatchTeammateIdleHook({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.attemptId, - sessionId, - previousState: previousWorkerState, - nextState: effectiveRuntimeWorkerState, - reason: waitingForInput ? "waiting_input_signal" : "runtime_idle_signal", - triggerSource: "runtime_signal", - runtimeState: signal.runtimeState, - preview: signal.lastOutputPreview, - laneId: signal.laneId || step.laneId || null - }); - } - - if (waitingForInput) { - const questionLink = buildQuestionThreadLink({ - attemptId: attempt.attemptId, - occurredAt: signal.at, - preview: signal.lastOutputPreview - }); - tracker.lastQuestionThreadId = questionLink.threadId; - tracker.lastQuestionMessageId = questionLink.messageId; - recordRuntimeEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.attemptId, - sessionId, - eventType: "question", - eventKey: questionLink.messageId, - occurredAt: signal.at, - payload: { - preview: signal.lastOutputPreview, - runtimeState: signal.runtimeState, - threadId: questionLink.threadId, - messageId: questionLink.messageId, - replyTo: questionLink.replyTo - } - }); - if (Date.now() - tracker.lastWaitingNotifiedAtMs >= 30_000) { - tracker.lastWaitingNotifiedAtMs = Date.now(); - emitOrchestratorMessage( - graph.run.missionId, - `Step "${stepTitleForMessage(step)}" is waiting for input from the worker. I opened intervention tracking and will resume scheduling once guidance is provided.`, - step.stepKey - ); - } - ensureManualInputIntervention({ - missionId: graph.run.missionId, - runId: attempt.runId, - stepId: step.id, - stepKey: step.stepKey, - stepTitle: stepTitleForMessage(step), - laneId: step.laneId ?? signal.laneId ?? null, - attemptId: attempt.attemptId, - sessionId, - preview: signal.lastOutputPreview, - questionLink, - reason: "runtime_signal" - }); - } else if (signal.lastOutputPreview && signal.lastOutputPreview.trim().length > 0) { - recordRuntimeEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.attemptId, - sessionId, - eventType: "progress", - eventKey: `progress:${attempt.attemptId}:${digestSignalText(signal.lastOutputPreview) ?? "none"}:${Math.floor(Date.parse(signal.at) / 1000)}`, - occurredAt: signal.at, - payload: { - preview: signal.lastOutputPreview, - runtimeState: signal.runtimeState - } - }); - maybeEmitWorkerProgressChatUpdate({ - missionId: graph.run.missionId, - runId: attempt.runId, - step, - attemptId: attempt.attemptId, - sessionId, - laneId: signal.laneId || step.laneId || null, - preview: signal.lastOutputPreview, - }); - } - persistAttemptRuntimeState({ - attemptId: attempt.attemptId, - sessionId, - runtimeState: signal.runtimeState, - lastSignalAt: signal.at, - lastOutputPreview: signal.lastOutputPreview, - force: waitingForInput || isTerminalSignal - }); - } - - if (!isTerminalSignal) return; - const sessionState = getTrackedSessionState(sessionId); - if (!sessionState || sessionState.status === "running") return; - for (const attempt of attempts) { - recordRuntimeEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.attemptId, - sessionId, - eventType: "session_ended", - eventKey: `session_ended_signal:${attempt.attemptId}:${sessionId}:${signal.runtimeState}:${signal.at}`, - occurredAt: signal.at, - payload: { - runtimeState: signal.runtimeState, - sessionStatus: sessionState.status, - exitCode: sessionState.exitCode - } - }); - } - let reconciled = 0; - try { - reconciled = await orchestratorService.onTrackedSessionEnded({ - sessionId, - laneId: signal.laneId || sessionState.laneId, - exitCode: sessionState.exitCode - }); - } catch (error) { - logger.debug("ai_orchestrator.runtime_signal_session_reconcile_failed", { - sessionId, - sessionStatus: sessionState.status, - runtimeState: signal.runtimeState, - error: error instanceof Error ? error.message : String(error) - }); - return; - } - if (reconciled <= 0) return; - for (const attempt of attempts) { - attemptRuntimeTrackers.delete(attempt.attemptId); - deletePersistedAttemptRuntimeState(attempt.attemptId); - if (syncedRunIds.has(attempt.runId)) continue; - syncedRunIds.add(attempt.runId); - void syncMissionFromRun(attempt.runId, "runtime_signal_session_end"); - } - }; - - const cleanupCoordinatorCheckpointFile = (runId: string, reason: string): void => { - if (!projectRoot) return; - void deleteCoordinatorCheckpoint(projectRoot, runId).catch((error) => { - logger.debug("ai_orchestrator.coordinator_checkpoint_cleanup_failed", { - runId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - }; - - const emitMilestoneReadinessToCoordinator = (args: { runId: string; reason: string }): void => { - let graph: OrchestratorRunGraph | null = null; - try { - graph = orchestratorService.getRunGraph({ runId: args.runId, timelineLimit: 0 }); - } catch { - return; - } - if (!graph) return; - - const completedDependencyStatuses = new Set([ - "succeeded", - "skipped", - "superseded", - "canceled", - ]); - - const readyMilestoneSignatureKeys = new Set(); - const readyMilestones = graph.steps.filter((step) => { - if (step.status !== "ready") return false; - const stepMeta = isRecord(step.metadata) ? step.metadata : {}; - return asBool(stepMeta.isMilestone, false); - }); - - for (const step of readyMilestones) { - const stepMeta = isRecord(step.metadata) ? step.metadata : {}; - const contract = isRecord(stepMeta.validationContract) ? stepMeta.validationContract : null; - const validationCriteriaRaw = - typeof contract?.criteria === "string" - ? contract.criteria - : typeof stepMeta.validationCriteria === "string" - ? stepMeta.validationCriteria - : typeof stepMeta.acceptanceCriteria === "string" - ? stepMeta.acceptanceCriteria - : ""; - const validationCriteria = - validationCriteriaRaw.trim().length > 0 - ? validationCriteriaRaw.trim() - : "No validation criteria documented in step metadata."; - const dependencySteps = step.dependencyStepIds - .map((dependencyStepId) => graph?.steps.find((candidate) => candidate.id === dependencyStepId)) - .filter((candidate): candidate is OrchestratorStep => Boolean(candidate)); - const dependenciesCompleted = dependencySteps.filter((dependencyStep) => - completedDependencyStatuses.has(dependencyStep.status) - ).length; - const dependencyTotal = dependencySteps.length; - const signature = [ - step.updatedAt, - dependenciesCompleted, - dependencyTotal, - validationCriteria, - ].join("::"); - const milestoneKey = `${args.runId}::${step.id}`; - readyMilestoneSignatureKeys.add(milestoneKey); - if (milestoneReadyNotificationSignatures.get(milestoneKey) === signature) { - continue; - } - milestoneReadyNotificationSignatures.set(milestoneKey, signature); - - const message = [ - `MILESTONE READY (GATED): "${stepTitleForMessage(step)}" (${step.stepKey})`, - `Dependencies completed: ${dependenciesCompleted}/${dependencyTotal}`, - `Validation criteria: ${validationCriteria}`, - "Action required: spawn validator worker, then call report_validation.", - "Downstream work stays gated until validation is reported.", - ].join("\n"); - - try { - orchestratorService.appendRuntimeEvent({ - runId: args.runId, - stepId: step.id, - eventType: "progress", - eventKey: `milestone_ready:${step.id}:${dependenciesCompleted}:${dependencyTotal}`, - payload: { - source: args.reason, - milestoneReady: true, - stepKey: step.stepKey, - stepTitle: stepTitleForMessage(step), - dependenciesCompleted, - dependencyTotal, - validationCriteria, - requiredAction: "spawn_validator_and_report_validation", - downstreamGated: true, - }, - }); - } catch { - // Non-fatal telemetry path. - } - - const coordAgent = coordinatorAgents.get(args.runId); - if (coordAgent?.isAlive) { - coordAgent.injectEvent( - { - type: "orchestrator-step-updated", - runId: args.runId, - stepId: step.id, - at: nowIso(), - reason: "milestone_ready_validation_required", - }, - message - ); - } - } - - for (const key of milestoneReadyNotificationSignatures.keys()) { - if (!key.startsWith(`${args.runId}::`)) continue; - if (!readyMilestoneSignatureKeys.has(key)) { - milestoneReadyNotificationSignatures.delete(key); - } - } - }; - - const hasOpenBlockingInterventionForRun = (missionId: string, runId: string): boolean => { - const mission = missionService.get(missionId); - if (!mission) return false; - return mission.status === "intervention_required" - || mission.interventions.some((intervention) => { - if (intervention.status !== "open") return false; - const interventionRunId = - typeof intervention.metadata?.runId === "string" ? intervention.metadata.runId.trim() : ""; - return interventionRunId.length === 0 || interventionRunId === runId; - }); - }; - - const startReadyAutopilotAttemptsWithMilestoneReadiness = async (args: { - runId: string; - reason: string; - }): Promise => { - const run = orchestratorService.listRuns({ limit: 1_000 }).find((entry) => entry.id === args.runId) ?? null; - if (!run) return 0; - if (run.status !== "active" && run.status !== "bootstrapping" && run.status !== "queued") return 0; - if (hasOpenBlockingInterventionForRun(run.missionId, run.id)) return 0; - const coordinator = coordinatorAgents.get(args.runId); - if (!coordinator?.isAlive) return 0; - emitMilestoneReadinessToCoordinator({ runId: args.runId, reason: args.reason }); - return orchestratorService.startReadyAutopilotAttempts({ runId: args.runId, reason: args.reason }); - }; - - const runHealthSweep = async (reason: string): Promise<{ sweeps: number; staleRecovered: number }> => { - if (disposed) return { sweeps: 0, staleRecovered: 0 }; - const startedAtMs = Date.now(); - pruneSessionRuntimeSignals(); - - // Prune expired call-type config cache entries - for (const [key, entry] of callTypeConfigCache.entries()) { - if (entry.expiresAt < startedAtMs) callTypeConfigCache.delete(key); - } - - // Cap workerProgressChatState to prevent unbounded growth - if (workerProgressChatState.size > 500) { - const sorted = [...workerProgressChatState.entries()] - .sort((a, b) => a[1].lastEmittedAtMs - b[1].lastEmittedAtMs); - for (const [key] of sorted.slice(0, sorted.length - 250)) { - workerProgressChatState.delete(key); - } - } - const runs = orchestratorService - .listRuns({ limit: HEALTH_SWEEP_ACTIVE_RUN_SCAN_LIMIT }) - .filter((run) => run.status === "active" || run.status === "bootstrapping" || run.status === "queued"); - let sweeps = 0; - let staleRecovered = 0; - let skippedBlockedRuns = 0; - - for (const run of runs) { - if (disposed) break; - if ((reason === "interval" || reason === "startup") && hasOpenBlockingInterventionForRun(run.missionId, run.id)) { - skippedBlockedRuns += 1; - continue; - } - let ownsHealthSweepRun = false; - if (activeHealthSweepRuns.has(run.id)) { - // Interval/startup sweeps are opportunistic; manual/chat/status sweeps should wait briefly - // so explicit health checks don't get dropped due to an in-flight background sweep. - if (reason === "interval" || reason === "startup") continue; - const deadline = Date.now() + 3_000; - while (activeHealthSweepRuns.has(run.id) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 25)); - } - if (activeHealthSweepRuns.has(run.id)) { - logger.debug("ai_orchestrator.health_sweep_explicit_overlap", { - runId: run.id, - reason - }); - continue; - } - } - if (!activeHealthSweepRuns.has(run.id)) { - activeHealthSweepRuns.add(run.id); - ownsHealthSweepRun = true; - } - try { - if (disposed) break; - sweeps += 1; - orchestratorService.tick({ runId: run.id }); - const graph = orchestratorService.getRunGraph({ runId: run.id, timelineLimit: 0 }); - const stepById = new Map(graph.steps.map((step) => [step.id, step] as const)); - - for (const attempt of graph.attempts.filter((entry) => entry.status === "running")) { - const step = stepById.get(attempt.stepId); - if (!step) continue; - if (attempt.executorKind === "manual") continue; - - const sessionId = typeof attempt.executorSessionId === "string" ? attempt.executorSessionId.trim() : ""; - const sessionSignal = sessionId.length > 0 ? sessionRuntimeSignals.get(sessionId) ?? null : null; - const sessionState = sessionId.length > 0 ? getTrackedSessionState(sessionId) : null; - if (sessionId.length > 0) { - if (sessionState && sessionState.status && sessionState.status !== "running") { - const endedAtMs = sessionState.endedAt ? Date.parse(sessionState.endedAt) : Number.NaN; - const endedRecently = Number.isFinite(endedAtMs) && Date.now() - endedAtMs < STALE_ATTEMPT_GRACE_MS; - if (!endedRecently) { - try { - const reconciled = await orchestratorService.onTrackedSessionEnded({ - sessionId, - laneId: sessionState.laneId, - exitCode: sessionState.exitCode - }); - if (reconciled > 0) { - staleRecovered += reconciled; - attemptRuntimeTrackers.delete(attempt.id); - deletePersistedAttemptRuntimeState(attempt.id); - emitOrchestratorMessage( - graph.run.missionId, - `Step "${stepTitleForMessage(step)}" session ended (${sessionState.status}${sessionState.exitCode == null ? "" : ` exit ${sessionState.exitCode}`}). Reconciled attempt state and advanced scheduling.`, - step.stepKey - ); - continue; - } - } catch (error) { - logger.debug("ai_orchestrator.health_sweep_session_reconcile_failed", { - runId: run.id, - attemptId: attempt.id, - sessionId, - sessionStatus: sessionState.status, - error: error instanceof Error ? error.message : String(error) - }); - } - } - } - } - - const ownerId = resolveAttemptOwnerId(graph.run, attempt); - try { - orchestratorService.heartbeatClaims({ - attemptId: attempt.id, - ownerId - }); - recordRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId: sessionId.length > 0 ? sessionId : null, - eventType: "heartbeat", - eventKey: `sweep_heartbeat:${attempt.id}:${Math.floor(Date.now() / WORKER_EVENT_HEARTBEAT_INTERVAL_MS)}`, - payload: { - source: "health_sweep", - ownerId - } - }); - } catch (error) { - logger.debug("ai_orchestrator.health_sweep_heartbeat_failed", { - runId: run.id, - attemptId: attempt.id, - ownerId, - error: error instanceof Error ? error.message : String(error) - }); - } - - const previewFromSignals = typeof sessionSignal?.lastOutputPreview === "string" ? sessionSignal.lastOutputPreview : null; - const previewFromSession = typeof sessionState?.lastOutputPreview === "string" ? sessionState.lastOutputPreview : null; - const effectivePreview = previewFromSignals ?? previewFromSession; - const runtimeWaiting = sessionSignal?.runtimeState === "waiting-input"; - const textWaiting = detectWaitingInputSignal(effectivePreview); - const waitingForInput = runtimeWaiting || textWaiting; - const idleLikeState = - sessionSignal?.runtimeState === "idle" - || sessionState?.status === "idle" - || sessionState?.status === "waiting-input"; - const runtimeStateForWorker: TerminalRuntimeState = waitingForInput - ? "waiting-input" - : idleLikeState - ? "idle" - : "running"; - const nextWorkerState = workerStateFromRuntimeSignal({ - runtimeState: runtimeStateForWorker, - waitingForInput - }); - const attemptMetadata = asRecord(attempt.metadata); - const workerSessionKind = typeof attemptMetadata?.workerSessionKind === "string" - ? attemptMetadata.workerSessionKind.trim() - : ""; - const effectiveNextWorkerState = - workerSessionKind === "managed_chat" - && attempt.executorSessionId - && !structuredChatSessions.has(attempt.executorSessionId) - && nextWorkerState === "working" - ? "initializing" - : nextWorkerState; - const stagnationSnapshot = updateAttemptStagnationTracker(attempt.id, effectivePreview); - - const previousWorkerState = workerStates.get(attempt.id)?.state ?? null; - upsertWorkerState(attempt.id, { - runId: run.id, - stepId: step.id, - sessionId: attempt.executorSessionId, - executorKind: attempt.executorKind, - state: effectiveNextWorkerState - }); - maybeDispatchTeammateIdleHook({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId: attempt.executorSessionId ?? null, - previousState: previousWorkerState, - nextState: effectiveNextWorkerState, - reason: waitingForInput ? "waiting_input_sweep" : "idle_like_sweep", - triggerSource: "health_sweep", - runtimeState: sessionSignal?.runtimeState ?? runtimeStateForWorker, - preview: effectivePreview, - laneId: step.laneId ?? null - }); - - if (waitingForInput && sessionId.length > 0) { - const questionLink = buildQuestionThreadLink({ - attemptId: attempt.id, - occurredAt: sessionSignal?.at ?? nowIso(), - preview: effectivePreview - }); - const tracker = ensureAttemptRuntimeTracker(attempt.id); - tracker.lastQuestionThreadId = questionLink.threadId; - tracker.lastQuestionMessageId = questionLink.messageId; - recordRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId, - eventType: "question", - eventKey: questionLink.messageId, - payload: { - source: "health_sweep", - preview: effectivePreview, - threadId: questionLink.threadId, - messageId: questionLink.messageId, - replyTo: questionLink.replyTo - } - }); - const now = Date.now(); - if (now - tracker.lastWaitingNotifiedAtMs >= 30_000) { - tracker.lastWaitingNotifiedAtMs = now; - emitOrchestratorMessage( - graph.run.missionId, - `Step "${stepTitleForMessage(step)}" is waiting for input from the worker. I am monitoring and will keep scheduling once guidance is provided.`, - step.stepKey - ); - } - ensureManualInputIntervention({ - missionId: graph.run.missionId, - runId: run.id, - stepId: step.id, - stepKey: step.stepKey, - stepTitle: stepTitleForMessage(step), - laneId: step.laneId ?? null, - attemptId: attempt.id, - sessionId, - preview: effectivePreview, - questionLink, - reason: "health_sweep" - }); - persistAttemptRuntimeState({ - attemptId: attempt.id, - sessionId, - runtimeState: "waiting-input", - lastSignalAt: sessionSignal?.at ?? nowIso(), - lastOutputPreview: effectivePreview, - force: true - }); - continue; - } - - persistAttemptRuntimeState({ - attemptId: attempt.id, - sessionId: sessionId.length > 0 ? sessionId : null, - runtimeState: sessionSignal?.runtimeState ?? null, - lastSignalAt: sessionSignal?.at ?? sessionState?.lastOutputAt ?? null, - lastOutputPreview: effectivePreview, - force: false - }); - - const startedAt = attempt.startedAt ?? attempt.createdAt; - const startedMs = Date.parse(startedAt); - if (!Number.isFinite(startedMs)) continue; - const elapsedMs = Date.now() - startedMs; - const workerStateMetadata = typeof attemptMetadata?.workerState === "string" - ? attemptMetadata.workerState.trim().toLowerCase() - : ""; - if ( - sessionId.length === 0 - && (workerStateMetadata === "initializing" || workerStateMetadata === "spawned" || !attempt.executorSessionId) - && elapsedMs > WORKER_STARTUP_ATTACH_TIMEOUT_MS - ) { - const elapsedSeconds = Math.max(1, Math.round(elapsedMs / 1000)); - const errorMessage = `Worker startup did not attach an executor session after ${elapsedSeconds}s.`; - try { - await orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage, - metadata: { - watchdogReason: reason, - watchdogRecoveredAt: nowIso(), - watchdogStartupAttachTimeoutMs: WORKER_STARTUP_ATTACH_TIMEOUT_MS, - watchdogElapsedMs: elapsedMs, - ownerId, - coordinatorNotified: Boolean(coordinatorAgents.get(run.id)?.isAlive) - } - }); - staleRecovered += 1; - emitOrchestratorMessage( - graph.run.missionId, - `Step "${stepTitleForMessage(step)}" did not attach an executor session and was moved into recovery.`, - step.stepKey - ); - } catch (error) { - logger.debug("ai_orchestrator.health_sweep_startup_attach_complete_failed", { - runId: run.id, - attemptId: attempt.id, - error: error instanceof Error ? error.message : String(error) - }); - } - continue; - } - const timeoutMs = resolveStepTimeoutMs({ - runId: run.id, - missionId: graph.run.missionId, - step - }); - if (elapsedMs <= timeoutMs + STALE_ATTEMPT_GRACE_MS) continue; - - const activityCandidates = [sessionSignal?.at ?? null, sessionState?.lastOutputAt ?? null] - .map((value) => (value ? Date.parse(value) : Number.NaN)) - .filter((value) => Number.isFinite(value)); - const lastActivityMs = activityCandidates.length > 0 ? Math.max(...activityCandidates) : Number.NaN; - const hasRecentSessionActivity = - Number.isFinite(lastActivityMs) && Date.now() - lastActivityMs <= STALE_ATTEMPT_GRACE_MS; - const hasRecentPreviewActivity = - stagnationSnapshot.digest != null && stagnationSnapshot.stagnantMs < STALE_ATTEMPT_GRACE_MS; - if (hasRecentSessionActivity || hasRecentPreviewActivity) { - continue; - } - - // Attempt exceeded timeout and has no recent activity. Notify the coordinator, - // then enforce a deterministic guardrail so attempts cannot run forever. - const elapsedMinutes = Math.max(1, Math.round(elapsedMs / 60_000)); - const timeoutMinutes = Math.max(1, Math.round(timeoutMs / 60_000)); - const stagnantMs = stagnationSnapshot.stagnantMs; - const stagnantMinutes = Math.max(1, Math.round(stagnantMs / 60_000)); - const coordAgent = coordinatorAgents.get(run.id); - const staleMessage = [ - `STALE ATTEMPT DETECTED: Step "${stepTitleForMessage(step)}" (${step.stepKey}) has exceeded its timeout.`, - `Elapsed: ${elapsedMinutes}m, Timeout: ${timeoutMinutes}m, No progress for: ${stagnantMinutes}m.`, - `Retry count: ${step.retryCount}/${step.retryLimit}.`, - `Attempt ID: ${attempt.id}. Step ID: ${step.id}.`, - `Attempt has been failed by timeout guardrail; decide recovery (retry/skip/workaround).`, - ].join("\n"); - recordRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId: sessionId.length > 0 ? sessionId : null, - eventType: "progress", - eventKey: `stale:${attempt.id}:${Math.floor(Date.now() / 60_000)}`, - payload: { - staleAttempt: true, - elapsedMs, - timeoutMs, - stagnantMs, - stepTitle: stepTitleForMessage(step), - stepKey: step.stepKey, - } - }); - if (coordAgent?.isAlive) { - try { - coordAgent.injectMessage(staleMessage); - } catch (error) { - logger.debug("ai_orchestrator.health_sweep_stale_notify_failed", { - runId: run.id, - attemptId: attempt.id, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - const errorMessage = `Attempt stagnating after timeout (${elapsedMinutes}m > ${timeoutMinutes}m) with ${stagnantMinutes}m no progress.`; - try { - await orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "transient", - errorMessage, - metadata: { - watchdogReason: reason, - watchdogRecoveredAt: nowIso(), - watchdogTimeoutMs: timeoutMs, - watchdogElapsedMs: elapsedMs, - watchdogStagnantMs: stagnantMs, - ownerId, - coordinatorNotified: Boolean(coordAgent?.isAlive) - } - }); - staleRecovered += 1; - emitOrchestratorMessage( - graph.run.missionId, - `Step "${stepTitleForMessage(step)}" exceeded timeout (${elapsedMinutes}m) and was marked failed by watchdog.`, - step.stepKey - ); - } catch (error) { - logger.debug("ai_orchestrator.health_sweep_complete_failed", { - runId: run.id, - attemptId: attempt.id, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : {}; - const autopilot = isRecord(runMeta.autopilot) ? runMeta.autopilot : null; - const autopilotEnabled = autopilot?.enabled === true; - if (autopilotEnabled) { - await startReadyAutopilotAttemptsWithMilestoneReadiness({ - runId: run.id, - reason: staleRecovered > 0 ? "health_sweep_recovered" : "health_sweep_tick" - }); - } else { - orchestratorService.tick({ runId: run.id }); - } - void syncMissionFromRun(run.id, `health_sweep:${reason}`); - } catch (error) { - logger.debug("ai_orchestrator.health_sweep_failed", { - runId: run.id, - reason, - error: error instanceof Error ? error.message : String(error) - }); - } finally { - if (ownsHealthSweepRun) activeHealthSweepRuns.delete(run.id); - } - } - - await replayQueuedWorkerMessages({ - reason: `health_sweep:${reason}` - }); - - logger.debug("ai_orchestrator.health_sweep_summary", { - reason, - sweeps, - staleRecovered, - skippedBlockedRuns, - activeRunCount: runs.length, - durationMs: Date.now() - startedAtMs, - }); - - return { sweeps, staleRecovered }; - }; - - // Deterministic retry decision removed — coordinator AI handles retry decisions - - const buildWorkerDigestFromAttempt = (args: Parameters[0]) => - buildWorkerDigestFromAttemptCtx(args); - - const updateWorkerStateFromEvent = (event: OrchestratorRuntimeEvent) => - updateWorkerStateFromEventCtx(ctx, event, { - recordRuntimeEvent, - evaluateWorkerPlan, - propagateHandoffContext, - steerMission, - handleInterventionWithAI, - appendChatMessage, - }); - - const MISSION_LAUNCH_FAILURE_REASON_CODE = "mission_launch_failed"; - - const describeMissionLaunchStage = (stage: MissionLaunchFailureStage): string => { - switch (stage) { - case "run_created": - return "run initialization"; - case "lane_create": - return "mission lane creation"; - case "coordinator_start": - return "coordinator startup"; - case "run_activate": - return "run activation"; - default: - return stage; - } - }; - - const clipErrorStack = (error: unknown, maxChars = 4_000): string | null => { - const stack = error instanceof Error && typeof error.stack === "string" ? error.stack.trim() : ""; - if (!stack.length) return null; - return stack.length > maxChars ? `${stack.slice(0, maxChars - 3)}...` : stack; - }; - - const buildMissionLaunchFailureMetadata = (args: { - runId: string; - failureStage: MissionLaunchFailureStage; - error: unknown; - }): Record => { - const rootError = getErrorMessage(args.error); - const rootErrorStack = clipErrorStack(args.error); - return { - runId: args.runId, - reasonCode: MISSION_LAUNCH_FAILURE_REASON_CODE, - failureStage: args.failureStage, - failureStageLabel: describeMissionLaunchStage(args.failureStage), - rootError, - ...(rootErrorStack ? { rootErrorStack } : {}), - coordinatorState: args.failureStage === "coordinator_start" ? "starting" : "not_started", - launchFailureBeforeCoordinator: args.failureStage !== "run_activate", - }; - }; - - const isMissionLaunchFailureMetadata = (metadata: Record | null | undefined, runId?: string | null): boolean => { - const reasonCode = typeof metadata?.reasonCode === "string" ? metadata.reasonCode.trim() : ""; - if (reasonCode !== MISSION_LAUNCH_FAILURE_REASON_CODE) return false; - if (!runId) return true; - const metadataRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - return metadataRunId.length === 0 || metadataRunId === runId; - }; - - const hasOpenMissionLaunchFailureIntervention = (args: { missionId: string; runId: string }): boolean => { - const mission = missionService.get(args.missionId); - if (!mission) return false; - return mission.interventions.some((entry) => { - if (entry.status !== "open") return false; - return isMissionLaunchFailureMetadata(isRecord(entry.metadata) ? entry.metadata : null, args.runId); - }); - }; - - const getRunLaunchFailureMetadata = (runId: string): Record | null => { - const metadata = getRunMetadata(runId); - const launchFailure = isRecord(metadata.launchFailure) ? metadata.launchFailure : null; - return isMissionLaunchFailureMetadata(launchFailure, runId) ? launchFailure : null; - }; - - const persistRunLaunchFailureMetadata = (runId: string, metadata: Record): void => { - updateRunMetadata(runId, (runMetadata) => { - runMetadata.launchFailure = { - ...(isRecord(runMetadata.launchFailure) ? runMetadata.launchFailure : {}), - ...metadata, - updatedAt: nowIso(), - }; - }); - }; - - const toMissionLaunchFailureBody = (args: { - failureStage: MissionLaunchFailureStage; - error: unknown; - }): string => { - return `ADE could not finish mission launch during ${describeMissionLaunchStage(args.failureStage)}.\n\n${getErrorMessage(args.error)}`; - }; - - const toMissionLaunchFailureError = (error: unknown, metadata: Record): Error => { - const base = error instanceof Error ? error : new Error(getErrorMessage(error)); - Object.assign(base, { - missionLaunchFailure: metadata, - }); - return base; - }; - - // ── Team Runtime Manager ───────────────────────────────────── - // Spawns and manages coordinator + teammates for a run. - - const getTeamMembersForRun = (runId: string): OrchestratorTeamMember[] => - getTeamMembersForRunCtx(ctx, runId); - - const updateTeamRuntimePhase = (runId: string, phase: OrchestratorTeamRuntimeState["phase"], extra?: Parameters[3]): void => - updateTeamRuntimePhaseCtx(ctx, runId, phase, extra); - - const getTeamRuntimeStateForRun = (runId: string): OrchestratorTeamRuntimeState | null => - getTeamRuntimeStateForRunCtx(ctx, runId); - - /** - * Finalize a run — the coordinator calls this when it believes the mission is complete. - * Validates that all tasks are done, no running attempts remain, and no blockers exist. - */ - const finalizeRun = (finalizeArgs: FinalizeRunArgs): FinalizeRunResult => { - const { runId } = finalizeArgs; - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const missionId = graph.run.missionId; - - // The act of calling finalizeRun IS the completion request - updateTeamRuntimePhase(runId, "executing", { completionRequested: true }); - const finalized = orchestratorService.finalizeRun(finalizeArgs); - if (!finalized.finalized) { - updateTeamRuntimePhase(runId, "executing", { - completionRequested: true, - completionValidated: false, - lastValidationError: finalized.blockers.join("; ") || null, - }); - return finalized; - } - - const finalStatus = finalized.finalStatus; - const ts = nowIso(); - updateTeamRuntimePhase(runId, "done", { - completionRequested: true, - completionValidated: true, - lastValidationError: null, - }); - - const keepCoordinatorOnline = finalStatus === "succeeded"; - if (!keepCoordinatorOnline) { - endCoordinatorAgentV2(runId); - cleanupCoordinatorCheckpointFile(runId, "finalize_run"); - } else { - setCoordinatorAvailability(runId, { - available: true, - mode: "consult_only", - summary: "Mission execution is complete. The coordinator remains available for follow-up questions and continuation requests.", - detail: "Post-completion messages stay in consult mode. New implementation work should create an explicit continuation.", - updatedAt: ts, - }, { graph }); - } - const finalGraph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - - const buildRecoveryHandoffPayload = () => { - const doneSteps = finalGraph.steps - .filter((step) => step.status === "succeeded" || step.status === "skipped" || step.status === "superseded" || step.status === "canceled") - .map((step) => ({ - stepId: step.id, - stepKey: step.stepKey, - title: step.title, - status: step.status, - laneId: step.laneId - })); - const remainingSteps = finalGraph.steps - .filter((step) => step.status !== "succeeded" && step.status !== "skipped" && step.status !== "superseded" && step.status !== "canceled") - .map((step) => ({ - stepId: step.id, - stepKey: step.stepKey, - title: step.title, - status: step.status, - laneId: step.laneId - })); - const laneMap = finalGraph.steps.reduce>((acc, step) => { - const laneKey = step.laneId ?? "unassigned"; - const list = acc[laneKey] ?? []; - list.push(step.stepKey); - acc[laneKey] = list; - return acc; - }, {}); - const validations = finalGraph.runtimeEvents - ?.filter((event) => event.eventType === "validation_report") - .slice(-25) - .map((event) => ({ - stepId: event.stepId, - attemptId: event.attemptId, - verdict: isRecord(event.payload) && typeof event.payload.verdict === "string" ? event.payload.verdict : "unknown", - summary: isRecord(event.payload) && typeof event.payload.summary === "string" ? event.payload.summary : null, - occurredAt: event.occurredAt - })) ?? []; - return { - schema: "ade.recoveryHandoff.v1", - runId, - missionId, - finalStatus, - generatedAt: ts, - doneSteps, - remainingSteps, - laneMap, - completedValidations: validations - }; - }; - - const persistRecoveryHandoff = (handoffType: "recovery_handoff") => { - const payload = buildRecoveryHandoffPayload(); - try { - orchestratorService.createHandoff({ - missionId, - runId, - handoffType, - producer: "coordinator", - payload - }); - } catch (error) { - logger.debug("ai_orchestrator.recovery_handoff_persist_failed", { - runId, - missionId, - handoffType, - error: error instanceof Error ? error.message : String(error) - }); - } - return payload; - }; - - if (finalStatus !== "succeeded") { - persistRecoveryHandoff("recovery_handoff"); - transitionMissionStatus(missionId, "failed"); - } - - const retrospective = (() => { - try { - return orchestratorService.generateRunRetrospective({ runId }); - } catch (error) { - logger.debug("ai_orchestrator.retrospective_generation_failed", { - runId, - missionId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - })(); - - 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, - missionId, - finalStatus, - blockerCount: finalized.blockers.length, - retrospectiveGenerated: Boolean(retrospective) - }); - - if (finalStatus === "succeeded") { - 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; - }; - - /** - * Resume active team runtimes after app restart. - * Checks for runs in "active" or "bootstrapping" status with coordinator sessions. - */ - const resumeActiveTeamRuntimes = (): void => { - void (async () => { - if (!hasRecoverableRuntimeWork()) return; - try { - const pageSize = 100; - let offset = 0; - while (true) { - const activeRuns = db.all<{ id: string; mission_id: string; status: string; metadata_json: string | null }>( - `select id, mission_id, status, metadata_json - from orchestrator_runs - where status in ('active', 'bootstrapping', 'queued') - order by created_at desc, id desc - limit ? OFFSET ?`, - [pageSize, offset] - ); - if (!activeRuns.length) break; - for (const run of activeRuns) { - const runtimeState = getTeamRuntimeStateForRun(run.id); - if (!runtimeState || runtimeState.phase === "done" || runtimeState.phase === "failed") continue; - - const mission = missionService.get(run.mission_id); - if (!mission) continue; - - const preResumeGraph = (() => { - try { - return orchestratorService.getRunGraph({ runId: run.id, timelineLimit: 0 }); - } catch { - return null; - } - })(); - const orphanedRunningAttempts = preResumeGraph?.attempts.filter((attempt) => attempt.status === "running") ?? []; - try { - orchestratorService.resumeRun({ runId: run.id }); - } catch (error) { - logger.debug("ai_orchestrator.team_runtime_resume_run_failed", { - runId: run.id, - missionId: run.mission_id, - error: error instanceof Error ? error.message : String(error), - }); - } - - const [stateDoc, checkpoint]: [MissionStateDocument | null, Awaited>] = projectRoot - ? await Promise.all([ - readMissionStateDocument({ projectRoot, runId: run.id }), - readCoordinatorCheckpoint(projectRoot, run.id), - ]) - : [null, null]; - - const coordinatorModelConfig = resolveOrchestratorModelConfig(run.mission_id, "coordinator"); - const missionGoal = mission.prompt || mission.title; - const resumeMissionLaneId = resolvePersistedMissionLaneIdForRun(run.id); - if (resumeMissionLaneId) { - persistMissionLaneIdForRun(run.id, resumeMissionLaneId); - } - const { userRules, projectCtx, availableProviders, phases } = gatherCoordinatorContext(run.mission_id, { missionId: run.mission_id }); - const agent = startCoordinatorAgentV2(run.mission_id, run.id, missionGoal, coordinatorModelConfig, { - userRules, - projectContext: projectCtx, - availableProviders, - phases, - skipInitialActivationMessage: true, - missionLaneId: resumeMissionLaneId ?? undefined, - }); - - if (agent) { - const truncate = (value: string, max: number): string => - value.length > max ? `${value.slice(0, max)}\n[truncated]` : value; - const serializedStateDoc = stateDoc - ? truncate(JSON.stringify(stateDoc, null, 2), 20_000) - : '{"note":"Mission state document unavailable."}'; - const checkpointSummary = checkpoint - ? [ - `savedAt=${checkpoint.savedAt}`, - `turnCount=${checkpoint.turnCount}`, - `compactionCount=${checkpoint.compactionCount}`, - `lastEventTimestamp=${checkpoint.lastEventTimestamp ?? "unknown"}`, - "", - truncate(checkpoint.conversationSummary || "No summary saved.", 8_000), - ].join("\n") - : "No checkpoint file found for this run."; - const recoveredAttemptsSummary = orphanedRunningAttempts.length - ? orphanedRunningAttempts - .slice(0, 12) - .map((attempt) => { - const step = preResumeGraph?.steps.find((candidate) => candidate.id === attempt.stepId); - const stepLabel = step ? `${step.stepKey} (${stepTitleForMessage(step)})` : attempt.stepId; - return `- ${stepLabel} / attempt ${attempt.attemptNumber} (${attempt.id})`; - }) - .join("\n") - : "- none"; - agent.injectMessage( - [ - "[RESUME RECOVERY]", - "App restart detected. Any orphaned running attempts were failed with errorClass 'resume_recovered' because worker sessions were lost.", - `Recovered attempts: ${orphanedRunningAttempts.length}`, - recoveredAttemptsSummary, - "", - "Authoritative mission state document:", - "```json", - serializedStateDoc, - "```", - "", - "Coordinator checkpoint summary:", - checkpointSummary, - "", - "Recovery instructions:", - "1. Call read_mission_status immediately to refresh current DAG truth.", - "2. Treat the mission state document above as the authoritative structured context.", - "3. Review steps affected by resume_recovered and decide retry/skip/workaround.", - "4. For milestone-ready gates, spawn validator workers and call report_validation before marking complete.", - "5. Continue execution from the current DAG state; do not redo completed work.", - ].join("\n") - ); - emitMilestoneReadinessToCoordinator({ runId: run.id, reason: "resume_active_team_runtimes" }); - logger.info("ai_orchestrator.team_runtime_resumed", { - runId: run.id, - missionId: run.mission_id, - phase: runtimeState.phase, - recoveredOrphanedAttempts: orphanedRunningAttempts.length, - hasMissionStateDoc: Boolean(stateDoc), - hasCheckpoint: Boolean(checkpoint), - }); - } - } - if (activeRuns.length < pageSize) break; - offset += activeRuns.length; - } - } catch (error) { - logger.debug("ai_orchestrator.team_runtime_resume_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - })(); - }; - - // Communication routing: user messages to coordinator thread get injected - // into the coordinator agent directly instead of going through the old - // text-command parser. - const routeUserMessageToCoordinator = ( - _missionId: string, - runId: string, - content: string, - mode: "conversation" | "instruction" = "conversation", - ): boolean => { - const coordAgent = coordinatorAgents.get(runId); - if (!coordAgent?.isAlive) return false; - const header = mode === "instruction" ? "[USER INSTRUCTION]" : "[USER CHAT]"; - const guidance = - mode === "instruction" - ? "Respond to the human in plain language. Their intent has already been recorded as mission steering when applicable. Do not forward their raw words to workers." - : "Answer the human directly in plain language. Do not treat this as worker steering unless they explicitly ask for an operational change."; - coordAgent.injectMessage(`${header}\n${guidance}\n\n${content}`); - return true; - }; - - - const respondToChatWithAI = async ( - chatArgs: SendOrchestratorChatArgs, - _precomputedRecentChatContext?: string - ): Promise => { - const mission = missionService.get(chatArgs.missionId); - if (!mission) return; - - const latestUserMessage = clipTextForContext(chatArgs.content, MAX_LATEST_CHAT_MESSAGE_CHARS); - const metadata = isRecord(chatArgs.metadata) ? chatArgs.metadata : null; - const chatMode = metadata?.coordinatorChatMode === "instruction" ? "instruction" : "conversation"; - - // Route user messages directly to the coordinator agent (tool-based) - const runs = orchestratorService.listRuns({ missionId: chatArgs.missionId }); - const latestRun = runs[0] ?? null; - const activeRun = runs.find((r) => r.status === "active" || r.status === "bootstrapping" || r.status === "queued" || r.status === "paused"); - if (activeRun) { - const routed = routeUserMessageToCoordinator(chatArgs.missionId, activeRun.id, latestUserMessage, chatMode); - if (routed) return; - if (activeRun.status === "paused") { - emitOrchestratorMessage( - chatArgs.missionId, - "Orchestrator runtime is currently paused or unavailable. Resume or restart the run before sending additional directives." - ); - return; - } - } - - if (latestRun && (latestRun.status === "succeeded" || latestRun.status === "failed" || latestRun.status === "canceled")) { - const coordAgent = coordinatorAgents.get(latestRun.id); - if (coordAgent?.isAlive) { - const consultMessage = `[FOLLOW-UP CONSULT ONLY]\nThe mission run is already terminal (${latestRun.status}). Answer questions, explain decisions, and recommend next steps. Do not mutate the completed run. If more implementation work is requested, instruct the operator to start a continuation.\n\nChat mode: ${chatMode}\nUser message:\n${latestUserMessage}`; - coordAgent.injectMessage(consultMessage); - return; - } - return; - } - - emitOrchestratorMessage( - chatArgs.missionId, - "Orchestrator runtime is not currently online for this mission. Start or resume the run to continue." - ); - }; - - const enqueueChatResponse = (chatArgs: SendOrchestratorChatArgs, recentChatContext: string): void => { - const missionId = chatArgs.missionId; - const previous = chatTurnQueues.get(missionId) ?? Promise.resolve(); - const next = previous - .catch((error) => { logger.warn("ai_orchestrator.chat_queue_previous_failed", { missionId, error: getErrorMessage(error) }); }) - .then(async () => { - await respondToChatWithAI(chatArgs, recentChatContext); - }) - .catch((error) => { - logger.debug("ai_orchestrator.chat_response_failed", { - missionId, - error: error instanceof Error ? error.message : String(error) - }); - emitOrchestratorMessage( - missionId, - "I captured your directive, but live response generation failed. I will still apply it to planning and evaluation." - ); - }) - .finally(() => { - if (chatTurnQueues.get(missionId) === next) { - chatTurnQueues.delete(missionId); - } - }); - chatTurnQueues.set(missionId, next); - }; - - // ── Event-Driven Coordinator with Heartbeat Safety Net ────────── - /** Debounce window for batching event-driven coordinator evaluations. */ - const COORDINATOR_EVAL_DEBOUNCE_MS = 500; - - /** - * Trigger an event-driven coordinator evaluation for a run. - * Multiple calls within COORDINATOR_EVAL_DEBOUNCE_MS are batched into one evaluation. - */ - const triggerCoordinatorEvaluation = (runId: string, reason: string): void => { - if (disposed || !aiIntegrationService || !projectRoot) return; - - const existing = pendingCoordinatorEvals.get(runId); - if (existing) clearTimeout(existing); - - pendingCoordinatorEvals.set(runId, setTimeout(() => { - pendingCoordinatorEvals.delete(runId); - runCoordinatorEvaluation(runId, reason); - }, COORDINATOR_EVAL_DEBOUNCE_MS)); - }; - - const runRequiresCoordinatorOwnership = (run: { metadata?: unknown } | null | undefined): boolean => { - const runMetadata = isRecord(run?.metadata) ? run.metadata : {}; - const runMode = typeof runMetadata.runMode === "string" ? runMetadata.runMode.trim().toLowerCase() : ""; - const autopilot = isRecord(runMetadata.autopilot) ? runMetadata.autopilot : null; - const autopilotExecutorKind = typeof autopilot?.executorKind === "string" - ? autopilot.executorKind.trim().toLowerCase() - : ""; - if (runMode === "manual") return false; - if (autopilot?.enabled === false) return false; - if (autopilotExecutorKind === "manual") return false; - return true; - }; - - /** - * Run an immediate coordinator evaluation for a run. - * Routes a rich status event to the coordinator agent and starts any newly-ready autopilot steps. - */ - const runCoordinatorEvaluation = (runId: string, reason: string): void => { - if (disposed) return; - - void (async () => { - try { - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 10 }); - if ( - graph.run.status !== "active" && graph.run.status !== "bootstrapping" && - graph.run.status !== "queued" - ) { - return; - } - if (!runRequiresCoordinatorOwnership(graph.run)) { - logger.debug("ai_orchestrator.coordinator_eval_skipped_manual_run", { - runId, - reason, - runMode: isRecord(graph.run.metadata) ? graph.run.metadata.runMode ?? null : null, - }); - return; - } - - const missionId = graph.run.missionId; - const launchFailure = getRunLaunchFailureMetadata(runId); - - const existingCoordinator = coordinatorAgents.get(runId) ?? null; - let coordinator = existingCoordinator?.isAlive ? existingCoordinator : null; - if (!coordinator && existingCoordinator && !existingCoordinator.isAlive) { - const recovered = attemptCoordinatorRecovery(runId); - if (recovered?.isAlive) coordinator = recovered; - } - if (!coordinator && hasActivePersistedCoordinatorChatSessionForRun(runId)) { - const ensured = ensureCoordinatorAgentV2(runId); - if (ensured.agent?.isAlive) coordinator = ensured.agent; - } - if (!coordinator) { - if (launchFailure && hasOpenMissionLaunchFailureIntervention({ missionId, runId })) { - logger.info("ai_orchestrator.coordinator_unavailable_suppressed", { - runId, - missionId, - reason: "launch_failure_open", - failureStage: launchFailure.failureStage ?? null, - }); - return; - } - pauseRunWithIntervention({ - runId, - missionId, - source: "transition_decision", - reasonCode: existingCoordinator ? "coordinator_recovery_failed" : "coordinator_unavailable", - title: existingCoordinator ? "Coordinator recovery failed" : "Coordinator unavailable", - body: existingCoordinator - ? "Coordinator agent terminated and could not be recovered. Mission paused to prevent non-autonomous fallback logic." - : "Coordinator agent is not available for this run. Mission paused to prevent non-autonomous fallback logic.", - requestedAction: "Resume after coordinator runtime is healthy, or restart the mission run.", - metadata: { - evaluationReason: reason - } - }); - logger.warn("ai_orchestrator.coordinator_eval_no_live_coordinator", { - runId, - missionId, - reason, - hadCoordinator: Boolean(existingCoordinator) - }); - return; - } - - // Route status event directly to coordinator agent - { - const now = Date.now(); - const runStartedAt = Date.parse(graph.run.createdAt); - const missionDurationSec = Math.round((now - runStartedAt) / 1000); - - const relevantSteps = filterExecutionSteps(graph.steps); - const runningSteps = relevantSteps.filter((s) => s.status === "running"); - const readySteps = relevantSteps.filter((s) => s.status === "ready"); - const completedSteps = relevantSteps.filter((s) => - s.status === "succeeded" || s.status === "failed" || s.status === "skipped" || s.status === "canceled" - ); - const blockedSteps = relevantSteps.filter((s) => s.status === "blocked"); - const pendingSteps = relevantSteps.filter((s) => s.status === "pending"); - - const stepGraph = graph.steps.map((s) => { - const deps = s.dependencyStepIds - .map((depId) => graph.steps.find((d) => d.id === depId)?.stepKey) - .filter(Boolean); - const latestAttempt = graph.attempts - .filter((a) => a.stepId === s.id) - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; - const resultPreview = latestAttempt?.resultEnvelope?.summary?.slice(0, 200) ?? ""; - const errorMsg = latestAttempt?.errorMessage?.slice(0, 150) ?? ""; - let line = ` ${s.stepKey} [${s.status}] "${s.title}"`; - if (deps.length) line += ` deps:[${deps.join(",")}]`; - if (resultPreview) line += ` result:"${resultPreview}"`; - if (errorMsg && s.status === "failed") line += ` error:"${errorMsg}"`; - if (s.status === "running") { - const elapsed = latestAttempt ? Math.round((Date.now() - Date.parse(latestAttempt.createdAt)) / 1000) : 0; - line += ` running_for:${elapsed}s`; - } - return line; - }); - - const statusMessage = [ - `[EVENT: ${reason}] Duration: ${missionDurationSec}s`, - `Progress: ${completedSteps.length}/${relevantSteps.length} executable steps done, ${runningSteps.length} running, ${readySteps.length} ready, ${blockedSteps.length} blocked, ${pendingSteps.length} pending`, - ...(() => { - const actionHints = buildCoordinatorEvaluationActionHints(graph); - if (actionHints.length === 0) return [] as string[]; - return ["", "Required next actions:", ...actionHints.map((hint) => `- ${hint}`)]; - })(), - "", - "Step graph:", - ...stepGraph, - ].join("\n"); - - coordinator.injectMessage(statusMessage); - } - - // Always start any newly-ready steps after evaluation - void startReadyAutopilotAttemptsWithMilestoneReadiness({ runId, reason: `coordinator_eval:${reason}` }).catch((error) => { - logger.warn("ai_orchestrator.start_ready_attempts_failed", { runId, reason: `coordinator_eval:${reason}`, error: getErrorMessage(error) }); - }); - - logger.debug("ai_orchestrator.coordinator_evaluation_triggered", { runId, missionId, reason }); - } catch (error) { - logger.debug("ai_orchestrator.coordinator_evaluation_failed", { - runId, - reason, - error: error instanceof Error ? error.message : String(error) - }); - } - })(); - }; - - - const evaluateWorkerPlan = async (args: { - attemptId: string; - workerPlan: Record; - provider: "claude" | "codex"; - }): Promise<{ approved: boolean; feedback: string }> => { - if (!aiIntegrationService || !projectRoot) { - logger.debug("ai_orchestrator.evaluate_worker_plan_not_available", { attemptId: args.attemptId }); - return { approved: false, feedback: "Cannot auto-approve — AI evaluation unavailable. Review manually." }; - } - - try { - const prompt = [ - PM_SYSTEM_PREAMBLE, - "Your current role: EVALUATOR. Assess whether the worker's output meets quality criteria.", - "", - "Worker output summary:", - JSON.stringify(args.workerPlan, null, 2), - "", - "Evaluate like a senior engineer reviewing a PR:", - "- Does the output actually accomplish what the step was supposed to do? Check against step instructions, not just general quality.", - "- Are there scope violations — files modified outside the worker's ownership fence?", - "- Is the output complete enough for downstream steps to build on?", - "- If rejecting: be specific about what needs to change. 'Auth middleware missing token refresh' beats 'incomplete'.", - "- If approving with caveats: note what downstream steps should watch for.", - "Be pragmatic — minor style issues should not block progress.", - "Return a JSON object with your evaluation." - ].join("\n"); - - const evaluationSchema = { - type: "object", - properties: { - approved: { type: "boolean" }, - feedback: { type: "string" }, - scopeViolations: { type: "array", items: { type: "string" } }, - alignmentScore: { type: "number", minimum: 0, maximum: 1 }, - suggestedAction: { type: "string", enum: ["accept", "retry_with_feedback", "add_corrective_step", "escalate"] } - }, - required: ["approved", "feedback", "suggestedAction"] - }; - - const runIdForAttempt = db.get<{ run_id: string | null }>( - ` - select run_id - from orchestrator_attempts - where id = ? - limit 1 - `, - [args.attemptId] - )?.run_id ?? null; - const missionIdForAttempt = runIdForAttempt ? getMissionIdForRun(runIdForAttempt) : null; - const configWorkerEval = missionIdForAttempt - ? resolveOrchestratorModelConfig(missionIdForAttempt, "coordinator") - : ({ provider: "claude", modelId: "claude-sonnet-4-6", thinkingLevel: "medium" } as ModelConfig); - const callTypeConfig = missionIdForAttempt - ? resolveCallTypeConfig(missionIdForAttempt, "coordinator") - : CALL_TYPE_DEFAULTS.coordinator; - - const result = await aiIntegrationService.executeTask({ - feature: "orchestrator", - taskType: "review", - prompt, - cwd: projectRoot, - provider: callTypeConfig.provider, - model: modelConfigToServiceModel(configWorkerEval), - reasoningEffort: callTypeConfig.reasoningEffort, - jsonSchema: evaluationSchema, - permissionMode: "read-only", - oneShot: true, - timeoutMs: 30_000 - }); - - const parsed = isRecord(result.structuredOutput) ? result.structuredOutput : null; - if (parsed) { - return { - approved: parsed.approved === true, - feedback: typeof parsed.feedback === "string" ? parsed.feedback : result.text - }; - } - return { approved: true, feedback: result.text || "Evaluation completed without structured output." }; - } catch (error) { - logger.warn("ai_orchestrator.evaluate_worker_plan_failed", { - attemptId: args.attemptId, - error: error instanceof Error ? error.message : String(error) - }); - // Don't silently auto-approve on failure — flag for review - return { approved: false, feedback: `AI evaluation failed (${error instanceof Error ? error.message : "unknown error"}). Flagging for manual review.` }; - } - }; - - // --------------------------------------------------------------------------- - // AI Plan Adjustment: evaluate and optimize the remaining plan after each - // step completion. This function is called when autoAdjustPlan is enabled. - // --------------------------------------------------------------------------- - - const adjustPlanWithAI = async (adjustArgs: { - runId: string; - completedStepId: string; - graph: OrchestratorRunGraph; - }): Promise => { - if (!aiIntegrationService || !projectRoot) return; - - const { graph } = adjustArgs; - const completed = graph.steps.filter((s) => s.status === "succeeded" || s.status === "failed" || s.status === "skipped"); - const remaining = graph.steps.filter((s) => s.status === "pending" || s.status === "ready" || s.status === "blocked"); - const targetStep = graph.steps.find((s) => s.id === adjustArgs.completedStepId); - - const steeringContext = getSteeringContext(graph.run.missionId); - - // Build rich step context so the AI can see actual results and dependency relationships - const stepDetails = graph.steps.map((s) => { - const deps = s.dependencyStepIds - .map((depId) => graph.steps.find((d) => d.id === depId)?.stepKey) - .filter(Boolean); - const latestAttempt = graph.attempts - .filter((a) => a.stepId === s.id) - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; - const resultSummary = latestAttempt?.resultEnvelope?.summary?.slice(0, 300) ?? ""; - return ` - ${s.stepKey} [${s.status}] "${s.title}"${deps.length ? ` depends_on:[${deps.join(",")}]` : " (no deps)"}${resultSummary ? ` result: "${resultSummary}..."` : ""}`; - }); - - // Identify parallelization opportunities for the AI - const blockedWithSingleDep = remaining.filter((s) => { - if (s.status !== "blocked") return false; - const depSteps = s.dependencyStepIds - .map((depId) => graph.steps.find((d) => d.id === depId)) - .filter(Boolean); - return depSteps.some((d) => d!.status === "succeeded" || d!.status === "skipped"); - }); - - const prompt = [ - PM_SYSTEM_PREAMBLE, - "Your current role: PLAN ADJUSTER. A step just completed. Evaluate the ENTIRE remaining plan aggressively.", - "", - "Your #1 priority: MAXIMIZE PARALLELISM. Every blocked step that doesn't truly need its dependency should be unblocked.", - "Your #2 priority: ELIMINATE WASTE. Skip steps that are now redundant given completed work.", - "Your #3 priority: FILL GAPS. Add steps only when completed work reveals a concrete missing piece.", - "", - "Think like a PM who hates idle workers:", - "- Look at each blocked step. Does it TRULY need its dependency, or was that dependency speculative at planning time?", - "- If step A's output is irrelevant to step B, remove the dependency even if the planner thought they were related.", - "- Can two pending steps be consolidated into one to save time?", - "- Should a step be reassigned to a different executor (claude vs codex) based on what we've learned?", - "- Did the completed step produce learnings that running/pending workers should know about?", - "", - `Run ID: ${adjustArgs.runId}`, - `Progress: ${completed.length}/${graph.steps.length} completed`, - `Last completed: ${targetStep?.stepKey ?? adjustArgs.completedStepId} [${targetStep?.status ?? "unknown"}]`, - "", - "Full step graph:", - ...stepDetails, - "", - blockedWithSingleDep.length > 0 ? `Parallelization opportunities — these blocked steps have some completed dependencies:\n${blockedWithSingleDep.map((s) => ` - ${s.stepKey}: "${s.title}"`).join("\n")}` : "", - steeringContext, - "", - "Available actions:", - "- skip_step: Skip a remaining step (set targetStepKey + reason)", - "- add_step: Add a new corrective step (set newStep with stepKey, title, instructions, dependencyStepKeys, executorKind)", - "- parallelize_steps: Remove a dependency from a step to unblock it (set targetStepKey + removeDependencyKey)", - "- consolidate_steps: Merge two pending/blocked steps into one (set targetStepKey=keep, removeStepKey=discard, mergedInstructions)", - "- reassign_executor: Change executor kind for a pending/blocked step (set targetStepKey + newExecutorKind: 'claude'|'codex'|'cursor'|'droid'|'opencode'|'manual')", - "- steer_worker: Send a message to a running worker with learnings from this completed step (set targetStepKey + steeringMessage)", - "- no_change: Nothing to adjust", - "", - "Be decisive. Respond with no_change ONLY if you genuinely see no optimization opportunity.", - "Return a JSON object with your adjustments." - ].join("\n"); - - const adjustmentSchema = { - type: "object", - properties: { - reasoning: { type: "string" }, - adjustments: { - type: "array", - items: { - type: "object", - properties: { - action: { type: "string", enum: ["add_step", "skip_step", "parallelize_steps", "consolidate_steps", "reassign_executor", "steer_worker", "no_change"] }, - targetStepKey: { type: "string" }, - reason: { type: "string" }, - // For parallelize_steps: which dependency to remove - removeDependencyKey: { type: "string" }, - // For consolidate_steps: which step to merge into targetStepKey - removeStepKey: { type: "string" }, - mergedInstructions: { type: "string" }, - // For reassign_executor - newExecutorKind: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "manual"] }, - // For steer_worker: message to send to running worker - steeringMessage: { type: "string" }, - // For add_step - newStep: { - type: "object", - properties: { - stepKey: { type: "string" }, - title: { type: "string" }, - instructions: { type: "string" }, - dependencyStepKeys: { type: "array", items: { type: "string" } }, - executorKind: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "manual"] } - } - } - }, - required: ["action", "reason"] - } - } - }, - required: ["reasoning", "adjustments"] - }; - - const missionId = graph.run.missionId; - const planAdjustConfig = resolveCallTypeConfig(missionId, "coordinator"); - const planAdjustModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); - - const result = await aiIntegrationService.executeTask({ - feature: "orchestrator", - taskType: "planning", - prompt, - cwd: projectRoot, - provider: planAdjustConfig.provider, - model: modelConfigToServiceModel(planAdjustModelConfig), - reasoningEffort: planAdjustConfig.reasoningEffort, - jsonSchema: adjustmentSchema, - permissionMode: "read-only", - oneShot: true, - timeoutMs: 30_000 - }); - - const parsed = isRecord(result.structuredOutput) ? result.structuredOutput : null; - if (!parsed || !Array.isArray(parsed.adjustments)) return; - - let adjustmentsApplied = 0; - for (const adj of parsed.adjustments) { - if (!isRecord(adj)) continue; - const action = String(adj.action ?? ""); - const reason = String(adj.reason ?? "AI-suggested adjustment"); - - if (action === "skip_step" && typeof adj.targetStepKey === "string") { - const target = graph.steps.find((s) => s.stepKey === adj.targetStepKey); - if (target && target.status !== "succeeded" && target.status !== "failed") { - try { - orchestratorService.skipStep({ runId: adjustArgs.runId, stepId: target.id, reason }); - emitOrchestratorMessage(graph.run.missionId, `AI adjuster skipped "${stepTitleForMessage(target)}": ${reason}`, target.stepKey); - logger.info("ai_orchestrator.ai_skip_step", { runId: adjustArgs.runId, stepKey: adj.targetStepKey, reason }); - adjustmentsApplied++; - } catch (e) { - logger.debug("ai_orchestrator.ai_skip_step_failed", { - runId: adjustArgs.runId, - stepKey: adj.targetStepKey, - error: e instanceof Error ? e.message : String(e) - }); - } - } - } else if (action === "add_step" && isRecord(adj.newStep)) { - const newStep = adj.newStep; - const stepKey = typeof newStep.stepKey === "string" ? newStep.stepKey : `ai-corrective-${Date.now()}`; - const title = typeof newStep.title === "string" ? newStep.title : "AI-suggested corrective step"; - const depKeys = Array.isArray(newStep.dependencyStepKeys) ? newStep.dependencyStepKeys.map(String) : []; - const executorKind = typeof newStep.executorKind === "string" && - ["claude", "codex", "cursor", "droid", "opencode", "manual"].includes(newStep.executorKind) - ? (newStep.executorKind as OrchestratorExecutorKind) - : ("opencode" as OrchestratorExecutorKind); - try { - orchestratorService.addSteps({ - runId: adjustArgs.runId, - steps: [{ - stepKey, - title, - stepIndex: graph.steps.length, - dependencyStepKeys: depKeys, - executorKind, - retryLimit: 1, - metadata: { - instructions: typeof newStep.instructions === "string" ? newStep.instructions : "", - aiGenerated: true, - generationReason: reason - } - }] - }); - emitOrchestratorMessage(graph.run.missionId, `AI adjuster added step "${title}": ${reason}`); - logger.info("ai_orchestrator.ai_add_step", { runId: adjustArgs.runId, stepKey, reason }); - adjustmentsApplied++; - } catch (e) { - logger.debug("ai_orchestrator.ai_add_step_failed", { - runId: adjustArgs.runId, - stepKey, - error: e instanceof Error ? e.message : String(e) - }); - } - } else if (action === "parallelize_steps" && typeof adj.targetStepKey === "string" && typeof adj.removeDependencyKey === "string") { - // Remove a dependency to unblock a step for parallel execution - const target = graph.steps.find((s) => s.stepKey === adj.targetStepKey); - if (target && (target.status === "blocked" || target.status === "pending" || target.status === "ready")) { - try { - const currentDepKeys = target.dependencyStepIds - .map((depId) => graph.steps.find((d) => d.id === depId)?.stepKey) - .filter((k): k is string => !!k); - const newDepKeys = currentDepKeys.filter((k) => k !== adj.removeDependencyKey); - orchestratorService.updateStepDependencies({ - runId: adjustArgs.runId, - stepId: target.id, - dependencyStepKeys: newDepKeys - }); - emitOrchestratorMessage( - graph.run.missionId, - `AI adjuster parallelized "${stepTitleForMessage(target)}" — removed dependency on "${adj.removeDependencyKey}": ${reason}`, - target.stepKey - ); - logger.info("ai_orchestrator.ai_parallelize_steps", { - runId: adjustArgs.runId, - stepKey: adj.targetStepKey, - removedDep: adj.removeDependencyKey, - reason - }); - adjustmentsApplied++; - } catch (e) { - logger.debug("ai_orchestrator.ai_parallelize_steps_failed", { - runId: adjustArgs.runId, - stepKey: adj.targetStepKey, - error: e instanceof Error ? e.message : String(e) - }); - } - } - } else if (action === "consolidate_steps" && typeof adj.targetStepKey === "string" && typeof adj.removeStepKey === "string") { - // Merge two steps into one - const keepStep = graph.steps.find((s) => s.stepKey === adj.targetStepKey); - const removeStep = graph.steps.find((s) => s.stepKey === adj.removeStepKey); - if (keepStep && removeStep && !["succeeded", "failed", "canceled"].includes(removeStep.status)) { - try { - orchestratorService.consolidateSteps({ - runId: adjustArgs.runId, - keepStepId: keepStep.id, - removeStepId: removeStep.id, - mergedInstructions: typeof adj.mergedInstructions === "string" ? adj.mergedInstructions : "" - }); - emitOrchestratorMessage( - graph.run.missionId, - `AI adjuster consolidated "${stepTitleForMessage(removeStep)}" into "${stepTitleForMessage(keepStep)}": ${reason}`, - keepStep.stepKey - ); - logger.info("ai_orchestrator.ai_consolidate_steps", { - runId: adjustArgs.runId, - keepStepKey: adj.targetStepKey, - removeStepKey: adj.removeStepKey, - reason - }); - adjustmentsApplied++; - } catch (e) { - logger.debug("ai_orchestrator.ai_consolidate_steps_failed", { - runId: adjustArgs.runId, - error: e instanceof Error ? e.message : String(e) - }); - } - } - } else if (action === "reassign_executor" && typeof adj.targetStepKey === "string" && typeof adj.newExecutorKind === "string") { - // Change executor kind for a pending/blocked step - // startAttempt reads executorKind from step.metadata.executorKind - const target = graph.steps.find((s) => s.stepKey === adj.targetStepKey); - if (target && (target.status === "blocked" || target.status === "pending" || target.status === "ready")) { - try { - const oldExecutorKind = isRecord(target.metadata) ? String(target.metadata.executorKind ?? "unknown") : "unknown"; - orchestratorService.updateStepMetadata({ - runId: adjustArgs.runId, - stepId: target.id, - metadata: { executorKind: adj.newExecutorKind, reassignReason: reason } - }); - emitOrchestratorMessage( - graph.run.missionId, - `AI adjuster reassigned "${stepTitleForMessage(target)}" from ${oldExecutorKind} → ${adj.newExecutorKind}: ${reason}`, - target.stepKey - ); - logger.info("ai_orchestrator.ai_reassign_executor", { - runId: adjustArgs.runId, - stepKey: adj.targetStepKey, - oldExecutorKind, - newExecutorKind: adj.newExecutorKind, - reason - }); - adjustmentsApplied++; - } catch (e) { - logger.debug("ai_orchestrator.ai_reassign_executor_failed", { - runId: adjustArgs.runId, - stepKey: adj.targetStepKey, - error: e instanceof Error ? e.message : String(e) - }); - } - } - } else if (action === "steer_worker" && typeof adj.targetStepKey === "string" && typeof adj.steeringMessage === "string") { - // Cross-worker intelligence: send learnings from completed step to a running worker - const target = graph.steps.find((s) => s.stepKey === adj.targetStepKey); - if (target && target.status === "running") { - const workerEntry = [...workerStates.entries()].find(([, ws]) => - ws.stepId === target.id && ws.state === "working" && ws.sessionId - ); - if (workerEntry && agentChatService) { - const [, workerWs] = workerEntry; - void (async () => { - try { - await sendWorkerMessageToSession( - workerWs.sessionId!, - `[ADE ORCHESTRATOR — CROSS-WORKER INTELLIGENCE]: ${adj.steeringMessage}` - ); - logger.debug("ai_orchestrator.ai_cross_worker_steer_delivered", { - runId: adjustArgs.runId, - fromStep: targetStep?.stepKey, - toStep: adj.targetStepKey - }); - } catch { - // Best-effort cross-worker steering - } - })(); - } - emitOrchestratorMessage( - graph.run.missionId, - `AI adjuster sent cross-worker insight to "${stepTitleForMessage(target)}": ${adj.steeringMessage!.slice(0, 200)}`, - target.stepKey - ); - logger.info("ai_orchestrator.ai_steer_worker", { - runId: adjustArgs.runId, - targetStepKey: adj.targetStepKey, - reason - }); - adjustmentsApplied++; - } - } - // no_change — just skip - } - - // After applying adjustments, immediately start any newly-ready steps - if (adjustmentsApplied > 0) { - void startReadyAutopilotAttemptsWithMilestoneReadiness({ - runId: adjustArgs.runId, - reason: `ai_plan_adjustment:${adjustmentsApplied}_changes` - }).catch((error) => { - logger.warn("ai_orchestrator.start_ready_attempts_failed", { runId: adjustArgs.runId, reason: `ai_plan_adjustment:${adjustmentsApplied}_changes`, error: getErrorMessage(error) }); - }); - logger.info("ai_orchestrator.ai_plan_adjustment_applied", { - runId: adjustArgs.runId, - adjustmentsApplied, - reasoning: typeof parsed.reasoning === "string" ? parsed.reasoning.slice(0, 500) : "" - }); - } - }; - - // --------------------------------------------------------------------------- - // Fan-out run state snapshot: gathers current run state for the meta-reasoner. - // --------------------------------------------------------------------------- - - // --------------------------------------------------------------------------- - // Cross-step handoff propagation: automatically enrich downstream steps - // with structured context from completed upstream steps. - // This is model-agnostic — any model benefits from richer input context. - // --------------------------------------------------------------------------- - - const propagateHandoffContext = (args: { - runId: string; - completedStepId: string; - digest: OrchestratorWorkerDigest; - }): void => { - try { - const graph = orchestratorService.getRunGraph({ runId: args.runId, timelineLimit: 0 }); - const completedStep = graph.steps.find((s) => s.id === args.completedStepId); - if (!completedStep) return; - - // Build structured handoff summary from digest - const parts: string[] = []; - parts.push(`[${completedStep.stepKey}] ${args.digest.summary.slice(0, 300)}`); - - if (args.digest.filesChanged.length > 0) { - const fileList = args.digest.filesChanged.slice(0, 15).join(", "); - const more = args.digest.filesChanged.length > 15 ? ` (+${args.digest.filesChanged.length - 15} more)` : ""; - parts.push(`Files changed: ${fileList}${more}`); - } - - if (args.digest.testsRun.failed > 0) { - parts.push(`Tests: ${args.digest.testsRun.passed} passed, ${args.digest.testsRun.failed} FAILED, ${args.digest.testsRun.skipped} skipped`); - } else if (args.digest.testsRun.passed > 0) { - parts.push(`Tests: ${args.digest.testsRun.passed} passed`); - } - - if (args.digest.warnings.length > 0) { - parts.push(`Warnings: ${args.digest.warnings.slice(0, 3).join("; ")}`); - } - - const handoffText = parts.join(". "); - if (!handoffText.trim().length) return; - - // Find all downstream steps that depend on the completed step - const downstreamSteps = graph.steps.filter((s) => { - if (s.status === "succeeded" || s.status === "failed" || s.status === "skipped" || s.status === "canceled") return false; - return s.dependencyStepIds.includes(args.completedStepId); - }); - - if (downstreamSteps.length === 0) return; - - const now = nowIso(); - for (const downstream of downstreamSteps) { - const meta = isRecord(downstream.metadata) ? { ...downstream.metadata } : {}; - const handoffKey = `${completedStep.id}:${args.digest.attemptId}`; - const existing = Array.isArray(meta.handoffSummaries) - ? [...meta.handoffSummaries as unknown[]].filter((entry) => typeof entry === "string" && entry.trim().length > 0) - : []; - const existingKeys = Array.isArray(meta.handoffSummaryKeys) - ? [...meta.handoffSummaryKeys as unknown[]].filter((entry) => typeof entry === "string" && entry.trim().length > 0) - : []; - const alreadyApplied = existingKeys.includes(handoffKey) || existing.includes(handoffText); - if (alreadyApplied) { - continue; - } - existing.push(handoffText); - existingKeys.push(handoffKey); - // Cap at 10 handoff entries to prevent prompt bloat - meta.handoffSummaries = existing.slice(-10); - meta.handoffSummaryKeys = existingKeys.slice(-10); - db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ? and run_id = ?`, - [JSON.stringify(meta), now, downstream.id, args.runId] - ); - } - - logger.debug("ai_orchestrator.handoff_propagated", { - runId: args.runId, - fromStepKey: completedStep.stepKey, - downstreamCount: downstreamSteps.length, - handoffLength: handoffText.length - }); - - // Tier 2 — AI evaluation on EVERY step completion - // The AI decides whether to adjust, not hardcoded conditions. - // This lets the orchestrator opportunistically parallelize, consolidate, and reorder. - const runtimeProfile = runRuntimeProfiles.get(args.runId) ?? resolveActiveRuntimeProfile(graph.run.missionId); - - const shouldTriggerAiAdjustment = - runtimeProfile.evaluation.autoAdjustPlan && - aiIntegrationService && - projectRoot; - - const completedCount = graph.steps.filter(s => s.status === "succeeded" || s.status === "failed" || s.status === "skipped").length; - const pct = Math.round((completedCount / graph.steps.length) * 100); - const activeWorkerCount = [...workerStates.values()].filter(ws => ws.runId === args.runId && ws.state === "working").length; - const blockedCount = graph.steps.filter(s => s.status === "blocked").length; - const nextReady = graph.steps.filter(s => s.status === "ready").map(s => `"${stepTitleForMessage(s)}"`); - - const progressParts = [`${completedCount}/${graph.steps.length} steps (${pct}%)`]; - if (activeWorkerCount > 0) progressParts.push(`${activeWorkerCount} active`); - if (blockedCount > 0) progressParts.push(`${blockedCount} blocked`); - if (nextReady.length > 0) progressParts.push(`next: ${nextReady.slice(0, 2).join(", ")}`); - - emitOrchestratorMessage( - graph.run.missionId, - `Progress: ${progressParts.join(" | ")}${shouldTriggerAiAdjustment ? " — triggering plan adjustment" : ""}`, - completedStep.stepKey - ); - - if (shouldTriggerAiAdjustment) { - adjustPlanWithAI({ - runId: args.runId, - completedStepId: args.completedStepId, - graph - }).catch((error) => { - logger.debug("ai_orchestrator.ai_adjustment_failed", { - runId: args.runId, - error: error instanceof Error ? error.message : String(error) - }); - }); - } - - // Event-driven coordinator trigger: evaluate plan immediately on step completion - triggerCoordinatorEvaluation(args.runId, `step_completed:${completedStep.stepKey}`); - } catch (error) { - logger.debug("ai_orchestrator.handoff_propagation_failed", { - runId: args.runId, - completedStepId: args.completedStepId, - error: error instanceof Error ? error.message : String(error) - }); - } - }; - - // ── Mission Pause Helpers ────────────────────────────────────────────── - // Create an intervention and transition mission to intervention_required. - // Running workers are NOT killed — they finish their current task. - - const pauseMissionWithIntervention = (args: { - missionId: string; - interventionType: "budget_limit_reached" | "provider_unreachable" | "unrecoverable_error"; - title: string; - body: string; - requestedAction: string; - metadata?: Record; - }): void => { - try { - const missionRuns = orchestratorService.listRuns({ missionId: args.missionId }); - for (const run of missionRuns) { - if (run.status === "active" || run.status === "bootstrapping" || run.status === "queued") { - try { - orchestratorService.pauseRun({ - runId: run.id, - reason: `${args.title}: ${args.body}`.slice(0, 400), - }); - } catch { - // ignore pause failures here; intervention creation still proceeds - } - const evalTimer = pendingCoordinatorEvals.get(run.id); - if (evalTimer) { - clearTimeout(evalTimer); - pendingCoordinatorEvals.delete(run.id); - } - } - if (run.status === "active" || run.status === "bootstrapping" || run.status === "queued" || run.status === "paused") { - endCoordinatorAgentV2(run.id); - } - } - missionService.addIntervention({ - missionId: args.missionId, - interventionType: args.interventionType, - title: args.title, - body: args.body, - requestedAction: args.requestedAction, - pauseMission: true, - metadata: args.metadata ?? null, - }); - emitOrchestratorMessage( - args.missionId, - `[MISSION PAUSED] ${args.title}: ${args.body.slice(0, 300)}`, - null, - { pauseReason: args.interventionType } - ); - - // Emit mission_paused timeline event for the activity feed - const pauseRuns = orchestratorService.listRuns({ missionId: args.missionId }); - const pauseRun = pauseRuns.find((r) => r.status === "active" || r.status === "bootstrapping" || r.status === "paused"); - if (pauseRun) { - orchestratorService.appendTimelineEvent({ - runId: pauseRun.id, - eventType: "mission_paused", - reason: `${args.interventionType}: ${args.title}`, - detail: { interventionType: args.interventionType, body: args.body.slice(0, 300) }, - }); - } - - logger.info("ai_orchestrator.mission_paused", { - missionId: args.missionId, - interventionType: args.interventionType, - title: args.title, - }); - } catch (err) { - logger.error("ai_orchestrator.pause_intervention_failed", { - missionId: args.missionId, - interventionType: args.interventionType, - error: err instanceof Error ? err.message : String(err), - }); - } - }; - - /** Pause mission when a budget hard cap is triggered by spawn_worker refusal. */ - const pauseOnBudgetHardCap = (missionId: string, detail: string): void => { - // Emit budget hard cap chat message + timeline event before pausing - emitOrchestratorMessage(missionId, `Budget pressure: hard_cap — ${detail}`); - const hardCapRuns = orchestratorService.listRuns({ missionId }); - const hardCapRun = hardCapRuns.find((r) => r.status === "active" || r.status === "bootstrapping" || r.status === "paused"); - if (hardCapRun) { - orchestratorService.appendTimelineEvent({ - runId: hardCapRun.id, - eventType: "budget_hard_cap_triggered", - reason: "Budget hard cap reached", - detail: { detail }, - }); - } - - pauseMissionWithIntervention({ - missionId, - interventionType: "budget_limit_reached", - title: "Budget hard cap reached", - body: detail, - requestedAction: "Raise budget limits, wait for the 5-hour window to reset, or cancel the mission.", - metadata: { source: "spawn_worker_hard_cap" }, - }); - }; - - const handleInterventionWithAI = async (args: { - missionId: string; - interventionId: string; - provider: "claude" | "codex"; - }): Promise<{ autoResolved: boolean; suggestion: string | null }> => { - if (!aiIntegrationService || !projectRoot) { - logger.debug("ai_orchestrator.handle_intervention_not_available", { - missionId: args.missionId, - interventionId: args.interventionId - }); - return { autoResolved: false, suggestion: null }; - } - - try { - const mission = missionService.get(args.missionId); - if (!mission) return { autoResolved: false, suggestion: null }; - - // Find the intervention - const intervention = mission.interventions.find((i) => i.id === args.interventionId); - const interventionReasonCode = - typeof intervention?.metadata?.reasonCode === "string" ? intervention.metadata.reasonCode.trim() : ""; - if (interventionReasonCode === "planner_plan_missing") { - const suggestion = - "Planner output is missing the canonical plan artifact. Retry planning only after the planner can return report_result.plan.markdown."; - emitOrchestratorMessage( - args.missionId, - `Intervention requires your input: ${intervention?.title ?? args.interventionId}. ${suggestion}` - ); - return { autoResolved: false, suggestion }; - } - const interventionDesc = intervention - ? `Type: ${intervention.interventionType}, Title: ${intervention.title}, Body: ${intervention.body}` - : `Intervention ID: ${args.interventionId}`; - - // Gather run graph context if available - const runs = orchestratorService.listRuns({ missionId: args.missionId }); - const activeRun = runs.find((r) => r.status === "active" || r.status === "bootstrapping" || r.status === "paused"); - let runContext = "No active run."; - if (activeRun) { - const graph = orchestratorService.getRunGraph({ runId: activeRun.id, timelineLimit: 10 }); - const done = graph.steps.filter((s) => s.status === "succeeded" || s.status === "skipped").length; - const failed = graph.steps.filter((s) => s.status === "failed").length; - const remaining = graph.steps.filter((s) => s.status === "pending" || s.status === "ready" || s.status === "blocked").length; - runContext = `Run ${activeRun.id.slice(0, 8)}: ${done} done, ${failed} failed, ${remaining} remaining of ${graph.steps.length} total steps.`; - } - - const runtimeProfile = resolveActiveRuntimeProfile(args.missionId); - const confidenceThreshold = runtimeProfile.evaluation.interventionConfidenceThreshold; - const steeringContext = getSteeringContext(args.missionId); - const prompt = buildInterventionResolverPrompt({ - missionTitle: mission.title, - missionPrompt: mission.prompt, - interventionDescription: interventionDesc, - runContext, - steeringContext, - confidenceThreshold - }); - - const interventionSchema = { - type: "object", - properties: { - autoResolvable: { type: "boolean" }, - confidence: { type: "number", minimum: 0, maximum: 1 }, - suggestedAction: { type: "string", enum: ["retry", "skip", "add_workaround", "escalate"] }, - reasoning: { type: "string" }, - retryInstructions: { type: "string" }, - workaroundStep: { - type: "object", - properties: { - title: { type: "string" }, - instructions: { type: "string" } - }, - additionalProperties: false - } - }, - required: ["autoResolvable", "confidence", "suggestedAction", "reasoning"], - additionalProperties: false - }; - - const configIntervention = resolveOrchestratorModelConfig(args.missionId, "coordinator"); - const timeoutMs = resolveAiDecisionLikeTimeoutMs(args.missionId); - const interventionCallConfig = resolveCallTypeConfig(args.missionId, "coordinator"); - const result = await aiIntegrationService.executeTask({ - feature: "orchestrator", - taskType: "review", - prompt, - cwd: projectRoot, - provider: interventionCallConfig.provider, - model: modelConfigToServiceModel(configIntervention), - reasoningEffort: interventionCallConfig.reasoningEffort, - jsonSchema: interventionSchema, - permissionMode: "read-only", - oneShot: true, - ...(timeoutMs != null ? { timeoutMs } : {}) - }); - - const parsed = isRecord(result.structuredOutput) ? result.structuredOutput : null; - if (!parsed) { - return { autoResolved: false, suggestion: result.text || null }; - } - - const autoResolvable = parsed.autoResolvable === true; - const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 0; - const suggestedAction = typeof parsed.suggestedAction === "string" ? parsed.suggestedAction : "escalate"; - const reasoning = typeof parsed.reasoning === "string" ? parsed.reasoning : ""; - - logger.info("ai_orchestrator.intervention_ai_assessment", { - missionId: args.missionId, - interventionId: args.interventionId, - autoResolvable, - confidence, - suggestedAction - }); - - if (autoResolvable && confidence >= confidenceThreshold && suggestedAction !== "escalate") { - // Auto-resolve: attach AI reasoning and resolve the intervention - if (intervention) { - try { - missionService.resolveIntervention({ - missionId: args.missionId, - interventionId: intervention.id, - status: "resolved", - note: `AI auto-resolved (confidence: ${confidence.toFixed(2)}): ${reasoning}` - }); - if (activeRun) { - recordRuntimeEvent({ - runId: activeRun.id, - eventType: "intervention_resolved", - eventKey: `intervention_resolved:${intervention.id}:ai_auto`, - payload: { - interventionId: intervention.id, - reason: "ai_auto_resolve", - confidence, - suggestedAction - } - }); - } - } catch { - // Intervention may already be resolved or not found - } - } - emitOrchestratorMessage( - args.missionId, - `Intervention auto-resolved: ${reasoning}` - ); - return { autoResolved: true, suggestion: reasoning }; - } - - // Low confidence or escalate action: attach suggestion but keep intervention open - emitOrchestratorMessage( - args.missionId, - `Intervention requires your input: ${intervention?.title ?? args.interventionId}. ${reasoning || ""}` - ); - return { autoResolved: false, suggestion: reasoning || null }; - } catch (error) { - logger.warn("ai_orchestrator.handle_intervention_failed", { - missionId: args.missionId, - interventionId: args.interventionId, - error: error instanceof Error ? error.message : String(error) - }); - return { autoResolved: false, suggestion: null }; - } - }; - - // ── Model Config Resolution (delegated to modelConfigResolver) ── - const resolveMissionDecisionTimeoutCapMs = (missionId: string) => - resolveMissionDecisionTimeoutCapMsCtx(ctx, missionId); - const resolveAiDecisionLikeTimeoutMs = (missionId: string) => - resolveAiDecisionLikeTimeoutMsCtx(ctx, missionId); - const resolveOrchestratorModelConfig = (missionId: string, callType: OrchestratorCallType) => - resolveOrchestratorModelConfigCtx(ctx, missionId, callType); - - // Delegated to missionLifecycle.ts - const transitionMissionStatus = ( - missionId: string, - next: MissionStatus, - args?: { outcomeSummary?: string | null; lastError?: string | null; allowTerminalRestart?: boolean } - ) => - transitionMissionStatusCtx(ctx, missionId, next, args); - - const COORDINATOR_FAILURE_REASON_CODES = new Set([ - "coordinator_unavailable", - "coordinator_recovery_failed", - "coordinator_runtime_provider_auth_failed", - "coordinator_runtime_model_unavailable", - "coordinator_runtime_permission_denied", - "coordinator_runtime_provider_unreachable", - "coordinator_runtime_cli_exit", - "coordinator_runtime_failed", - ]); - - const resolveCoordinatorHealthInterventions = (args: { - runId: string; - note: string; - resolutionReason: "coordinator_recovered" | "run_terminal"; - }): number => { - const missionId = getMissionIdForRun(args.runId); - if (!missionId) return 0; - const mission = missionService.get(missionId); - if (!mission) return 0; - - let resolved = 0; - const resolvedAt = nowIso(); - - for (const intervention of mission.interventions) { - if (intervention.status !== "open") continue; - const metadata = isRecord(intervention.metadata) ? intervention.metadata : null; - const reasonCode = typeof metadata?.reasonCode === "string" ? metadata.reasonCode.trim() : ""; - const interventionRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - const coordinatorFailureHandled = metadata?.coordinatorFailureHandled === true; - if (!COORDINATOR_FAILURE_REASON_CODES.has(reasonCode) && !coordinatorFailureHandled) continue; - if (interventionRunId.length > 0 && interventionRunId !== args.runId) continue; - - try { - missionService.resolveIntervention({ - missionId, - interventionId: intervention.id, - status: "resolved", - note: args.note, - }); - recordRuntimeEvent({ - runId: args.runId, - stepId: typeof metadata?.stepId === "string" ? metadata.stepId : null, - attemptId: typeof metadata?.attemptId === "string" ? metadata.attemptId : null, - sessionId: typeof metadata?.sessionId === "string" ? metadata.sessionId : null, - eventType: "intervention_resolved", - eventKey: `intervention_resolved:${intervention.id}:${args.resolutionReason}`, - payload: { - interventionId: intervention.id, - reason: args.resolutionReason, - reasonCode, - resolvedAt, - } - }); - resolved += 1; - } catch (error) { - logger.debug("ai_orchestrator.coordinator_intervention_resolve_failed", { - runId: args.runId, - interventionId: intervention.id, - reasonCode, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return resolved; - }; - - const pauseRunWithIntervention = (args: { - runId: string; - missionId: string; - stepId?: string | null; - stepKey?: string | null; - source: "transition_decision"; - interventionType?: MissionDetail["interventions"][number]["interventionType"]; - reasonCode: string; - title: string; - body: string; - requestedAction: string; - metadata?: Record; - }): string | null => { - const dedupeKey = `${args.source}:${args.reasonCode}:${args.runId}:${args.stepId ?? "run"}`; - const runMetadata = getRunMetadata(args.runId); - const existingAiDecisionMeta = isRecord(runMetadata.aiDecisions) ? runMetadata.aiDecisions : {}; - const mission = missionService.get(args.missionId); - let existingInterventionId: string | null = null; - if (mission) { - for (const entry of mission.interventions) { - if (entry.status !== "open") continue; - const metadata = isRecord(entry.metadata) ? entry.metadata : null; - if (metadata?.aiDecisionFailureKey === dedupeKey) { - existingInterventionId = entry.id; - break; - } - } - } - - try { - orchestratorService.pauseRun({ - runId: args.runId, - reason: `${args.title}: ${args.body}`.slice(0, 400), - metadata: { - aiDecisions: { - ...existingAiDecisionMeta, - lastFailureAt: nowIso(), - lastFailureSource: args.source, - lastFailureReason: args.reasonCode - } - } - }); - const evalTimer = pendingCoordinatorEvals.get(args.runId); - if (evalTimer) { - clearTimeout(evalTimer); - pendingCoordinatorEvals.delete(args.runId); - } - endCoordinatorAgentV2(args.runId); - } catch (error) { - logger.debug("ai_orchestrator.pause_run_failed", { - runId: args.runId, - source: args.source, - reasonCode: args.reasonCode, - error: error instanceof Error ? error.message : String(error) - }); - } - - let interventionId: string | null = existingInterventionId; - if (!interventionId) { - try { - const intervention = missionService.addIntervention({ - missionId: args.missionId, - interventionType: args.interventionType ?? "failed_step", - title: args.title, - body: args.body, - requestedAction: args.requestedAction, - metadata: { - aiDecisionFailureKey: dedupeKey, - runId: args.runId, - stepId: args.stepId ?? null, - stepKey: args.stepKey ?? null, - source: args.source, - reasonCode: args.reasonCode, - ...(args.metadata ?? {}) - } - }); - interventionId = intervention.id; - } catch (error) { - logger.debug("ai_orchestrator.pause_intervention_create_failed", { - runId: args.runId, - missionId: args.missionId, - source: args.source, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - transitionMissionStatus(args.missionId, "intervention_required", { - lastError: args.body - }); - emitOrchestratorMessage( - args.missionId, - `${args.title}. I paused execution and opened an intervention${interventionId ? ` (${interventionId.slice(0, 8)})` : ""}.`, - args.stepKey ?? null - ); - - if (interventionId) { - recordRuntimeEvent({ - runId: args.runId, - stepId: args.stepId ?? null, - eventType: "intervention_opened", - eventKey: `intervention_opened:${interventionId}`, - payload: { - audience: "mission_feed", - interventionId, - interventionType: args.interventionType ?? "failed_step", - source: args.source, - reasonCode: args.reasonCode, - title: args.title, - body: args.body, - requestedAction: args.requestedAction, - summary: args.body, - } - }); - } - - updateMissionStateDoc(args.runId, { - pendingInterventions: pendingInterventionsForMission(args.missionId), - }); - - return interventionId; - }; - - const stableString = (value: unknown): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }; - - const resolveMissionStepForRunStep = ( - mission: MissionDetail, - runStep: OrchestratorRunGraph["steps"][number], - missionStepById: Map = new Map(mission.steps.map((step) => [step.id, step])), - ): MissionDetail["steps"][number] | null => { - if (runStep.missionStepId) { - const byId = missionStepById.get(runStep.missionStepId); - if (byId) return byId; - } - const runStepId = stableString(runStep.id); - const runStepKey = stableString(runStep.stepKey); - return mission.steps.find((step) => { - const metadata = isRecord(step.metadata) ? step.metadata : null; - if (!metadata) return false; - const metadataOrchestratorStepId = stableString(metadata.orchestratorStepId); - if (runStepId && metadataOrchestratorStepId === runStepId) return true; - const metadataStepKey = stableString(metadata.stepKey); - return Boolean(runStepKey && metadataStepKey === runStepKey); - }) ?? null; - }; - - const syncMissionStepsFromRun = (graph: OrchestratorRunGraph) => { - const mission = missionService.get(graph.run.missionId); - if (!mission) return; - const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); - - for (const runStep of graph.steps) { - const missionStep = resolveMissionStepForRunStep(mission, runStep, missionStepById); - if (!missionStep) continue; - const nextStatus = mapOrchestratorStepStatus(runStep.status); - if (missionStep.status === nextStatus) continue; - - const apply = (status: MissionStepStatus) => { - missionService.updateStep({ - missionId: mission.id, - stepId: missionStep.id, - status, - note: `Synchronized from orchestrator run ${graph.run.id.slice(0, 8)} at ${nowIso()}.` - }); - }; - - try { - apply(nextStatus); - // Emit blocked event when a step transitions to blocked - if (nextStatus === "blocked" && missionStep.status !== "blocked") { - recordRuntimeEvent({ - runId: graph.run.id, - stepId: runStep.id, - eventType: "blocked", - eventKey: `blocked:${runStep.id}`, - payload: { - stepKey: runStep.stepKey, - previousStatus: missionStep.status - } - }); - } - } catch { - // A few transitions are stricter on mission steps. Step through running first when needed. - if (missionStep.status === "pending" && (nextStatus === "succeeded" || nextStatus === "failed")) { - try { - apply("running"); - } catch { - // ignore - } - try { - apply(nextStatus); - } catch { - // ignore - } - } - } - } - }; - - const ensureTerminalFailedRunIntervention = (graph: OrchestratorRunGraph) => { - if (graph.run.status !== "failed") return; - const mission = missionService.get(graph.run.missionId); - if (!mission) return; - - const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); - const hasMatchingOpenIntervention = ( - failedRunStep: OrchestratorRunGraph["steps"][number] | null, - failedMissionStep: MissionDetail["steps"][number] | null, - ) => mission.interventions.some((entry) => { - if (entry.status !== "open" || entry.interventionType !== "failed_step") return false; - const metadata = isRecord(entry.metadata) ? entry.metadata : null; - if (!metadata) return false; - if (failedMissionStep?.id && metadata.stepId === failedMissionStep.id) return true; - return Boolean(!failedMissionStep && failedRunStep?.id && metadata.orchestratorStepId === failedRunStep.id); - }); - const failedRunPair = graph.steps - .filter((step) => step.status === "failed") - .map((runStep) => ({ - runStep, - missionStep: resolveMissionStepForRunStep(mission, runStep, missionStepById), - })) - .find(({ runStep, missionStep }) => !hasMatchingOpenIntervention(runStep, missionStep)); - const fallbackMissionStep = failedRunPair - ? null - : mission.steps.find((step) => - step.status === "failed" && !hasMatchingOpenIntervention(null, step) - ) ?? null; - const failedRunStep = failedRunPair?.runStep ?? null; - const failedMissionStep = failedRunPair?.missionStep ?? fallbackMissionStep; - if (!failedRunStep && !failedMissionStep) return; - const failedStepTitle = failedMissionStep?.title ?? failedRunStep?.title ?? "Run step"; - - missionService.addIntervention({ - missionId: mission.id, - interventionType: "failed_step", - title: `Step failed: ${failedStepTitle}`, - body: `Synchronized from failed orchestrator run ${graph.run.id.slice(0, 8)}.`, - requestedAction: "Review the failure and decide whether to continue, retry, or cancel.", - metadata: { - runId: graph.run.id, - stepId: failedMissionStep?.id ?? null, - stepKey: failedRunStep?.stepKey ?? null, - orchestratorStepId: failedRunStep?.id ?? null, - reasonCode: "terminal_run_failed_step", - } - }); - if (mission.status !== "intervention_required") { - transitionMissionStatus(mission.id, "intervention_required", { allowTerminalRestart: true }); - } - }; - - // TERMINAL_PHASE_STEP_STATUSES — imported from missionLifecycle - - const syncMissionPhaseFromRun = (graph: OrchestratorRunGraph, _reason: string) => { - if (!graph.run.missionId) return; - const target = deriveMissionPhaseSyncTarget(graph); - if (!target) return; - updateMissionStateDoc(graph.run.id, { - updateProgress: { - currentPhase: target.phaseName, - }, - }, { graph }); - }; - - const syncMissionFromRun = async ( - runId: string, - reason: string, - options?: { nextMissionStatus?: MissionStatus | null } - ) => { - 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); - if (!mission) return; - - syncMissionStepsFromRun(graph); - ensureTerminalFailedRunIntervention(graph); - syncMissionPhaseFromRun(graph, reason); - const refreshed = missionService.get(mission.id) ?? mission; - let nextMissionStatus = options?.nextMissionStatus ?? deriveMissionStatusFromRun(graph, refreshed); - if (nextMissionStatus === "completed") { - let workersSpawned = false; - const missionForFinalization = missionService.get(mission.id) ?? refreshed; - const finalizationPolicy = resolveMissionFinalizationPolicyForMission(missionForFinalization.id); - const missionBaseBranch = await resolveMissionBaseBranch(missionForFinalization.id); - - // ── Guard: skip duplicate finalization ────────────────────── - // If a previous sync already completed finalization or is actively - // finalizing with a result lane, bail out to avoid creating - // duplicate result lanes from concurrent sync calls. - let skipFinalization = false; - const existingStateDoc = projectRoot - ? await readMissionStateDocument({ projectRoot, runId }).catch(() => null) - : null; - const existingFinalization = existingStateDoc?.finalization ?? null; - if (existingFinalization) { - const alreadyCompleted = existingFinalization.status === "completed"; - const alreadyFinalizingWithResult = - existingFinalization.status === "finalizing" && !!existingFinalization.resultLaneId; - if (alreadyCompleted || alreadyFinalizingWithResult) { - skipFinalization = true; - logger.debug("ai_orchestrator.finalization_skipped_already_terminal", { - runId, - missionId: mission.id, - existingStatus: existingFinalization.status, - existingResultLaneId: existingFinalization.resultLaneId, - }); - emitOrchestratorMessage( - mission.id, - alreadyCompleted - ? `Finalization already completed (result lane: ${existingFinalization.resultLaneId ?? "none"}).` - : `Finalization already in progress (result lane: ${existingFinalization.resultLaneId}).` - ); - // Still transition mission status so the sync path completes - // consistently – the closeout check below handles it. - if (alreadyCompleted && existingFinalization.contractSatisfied) { - transitionMissionStatus(mission.id, "completed", { - outcomeSummary: refreshed.outcomeSummary ?? buildOutcomeSummary(graph), - lastError: null, - }); - } - } - } - - if (!skipFinalization) { - try { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalizing", - executionComplete: true, - contractSatisfied: false, - blocked: false, - summary: "Execution completed. ADE is assembling the mission result lane.", - detail: finalizationPolicy.description, - warnings: [], - completedAt: null, - }, { graph }); - - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalizing", - executionComplete: true, - contractSatisfied: false, - blocked: false, - summary: "Execution finished. ADE is assembling the mission result lane.", - detail: `Base branch: ${missionBaseBranch ?? "main"}`, - warnings: [], - }, { graph }); - - const result = await finalizeMissionToResultLane({ - mission: missionForFinalization, - runId, - missionBaseBranch: missionBaseBranch ?? "main", - }); - - if (result.warnings.length > 0) { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalizing", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: result.detail, - resultLaneId: result.resultLaneId, - summary: result.summary, - detail: result.detail, - warnings: result.warnings, - }, { graph }); - missionService.addIntervention({ - missionId: mission.id, - interventionType: "conflict", - title: "Result lane merge conflict", - body: `ADE assembled a partial result lane but could not finish merging all mission lanes.\n\n${result.detail}`, - requestedAction: "Resolve the conflicts in the result lane, then continue from that lane or rerun finalization.", - laneId: result.resultLaneId, - metadata: { - runId, - resultLaneId: result.resultLaneId, - mergeWarnings: result.warnings, - }, - }); - // Transition mission to intervention_required so the conflict - // surfaces in getScopedOpenIntervention / getRunView. - transitionMissionStatus(mission.id, "intervention_required", { - lastError: `Result lane merge conflict: ${result.resultLaneId}`, - }); - emitOrchestratorMessage( - mission.id, - `Result lane blocked by merge conflicts: ${result.resultLaneId}` - ); - } else { - emitOrchestratorMessage( - mission.id, - `Result lane ready: ${result.resultLaneId}` - ); - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "completed", - executionComplete: true, - contractSatisfied: true, - blocked: false, - resultLaneId: result.resultLaneId, - summary: result.summary, - detail: result.detail, - warnings: result.warnings, - completedAt: nowIso(), - }, { graph }); - } - } catch (finalizationError) { - const finalizationErrorMessage = finalizationError instanceof Error ? finalizationError.message : String(finalizationError); - logger.debug("ai_orchestrator.result_lane_finalization_failed", { - runId, - missionId: mission.id, - error: finalizationErrorMessage - }); - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalization_failed", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: finalizationErrorMessage, - summary: "Mission finalization failed.", - detail: finalizationErrorMessage, - warnings: [], - }, { graph }); - transitionMissionStatus(mission.id, "failed", { - lastError: finalizationErrorMessage, - }); - } - - } // end if (!skipFinalization) - - // Deferred mission completion: only transition to "completed" if no - // conflict resolution workers were spawned. When workers ARE spawned, - // the run was reopened (succeeded → running) and the mission should - // stay in_progress until the workers complete. - let stateDocAfterFinalization = projectRoot - ? await readMissionStateDocument({ projectRoot, runId }).catch(() => null) - : null; - if (stateDocAfterFinalization?.finalization) { - const missionForCloseout = missionService.get(mission.id) ?? missionForFinalization; - const closeoutRequirements = buildMissionCloseoutRequirements({ - mission: missionForCloseout, - graph, - policy: stateDocAfterFinalization.finalization.policy, - finalization: stateDocAfterFinalization.finalization, - stateDoc: stateDocAfterFinalization, - }); - const unmetRequirements = closeoutRequirements.filter((requirement) => - requirement.required - && requirement.status !== "present" - && requirement.status !== "waived" - ); - const closeoutSatisfied = unmetRequirements.length === 0; - const finalizationBlocked = - stateDocAfterFinalization.finalization.status === "finalization_failed" - || stateDocAfterFinalization.finalization.blocked === true; - const nextFinalizationStatus = stateDocAfterFinalization.finalization.status === "finalization_failed" - ? "finalization_failed" - : finalizationBlocked - ? stateDocAfterFinalization.finalization.status - : closeoutSatisfied - ? "completed" - : stateDocAfterFinalization.finalization.status === "completed" - ? "finalizing" - : stateDocAfterFinalization.finalization.status; - await updateMissionFinalizationState(runId, { - policy: stateDocAfterFinalization.finalization.policy, - status: nextFinalizationStatus, - executionComplete: true, - contractSatisfied: !finalizationBlocked && closeoutSatisfied, - requirements: closeoutRequirements, - summary: finalizationBlocked - ? stateDocAfterFinalization.finalization.summary - : unmetRequirements.length > 0 - ? "Execution finished, but the closeout contract is still incomplete." - : stateDocAfterFinalization.finalization.summary?.includes("closeout contract is still incomplete") - ? "Mission finalization completed." - : stateDocAfterFinalization.finalization.summary, - detail: finalizationBlocked - ? stateDocAfterFinalization.finalization.detail - : unmetRequirements.length > 0 - ? unmetRequirements.map((requirement) => requirement.label).join(", ") - : stateDocAfterFinalization.finalization.detail, - completedAt: !finalizationBlocked && closeoutSatisfied ? (stateDocAfterFinalization.finalization.completedAt ?? nowIso()) : null, - }, { graph }); - stateDocAfterFinalization = projectRoot - ? await readMissionStateDocument({ projectRoot, runId }).catch(() => null) - : stateDocAfterFinalization; - } - const finalizationSatisfied = stateDocAfterFinalization?.finalization?.contractSatisfied ?? false; - const finalizationFailed = stateDocAfterFinalization?.finalization?.status === "finalization_failed"; - if (finalizationFailed) { - transitionMissionStatus(mission.id, "failed", { - lastError: stateDocAfterFinalization?.finalization?.blockedReason ?? stateDocAfterFinalization?.finalization?.detail ?? "Mission finalization failed." - }); - } else if (!workersSpawned && finalizationSatisfied) { - transitionMissionStatus(mission.id, "completed", { - outcomeSummary: refreshed.outcomeSummary ?? buildOutcomeSummary(graph), - lastError: null, - allowTerminalRestart: true - }); - } - } else if (nextMissionStatus === "failed") { - const latestMission = missionService.get(mission.id) ?? refreshed; - const latestStatus = graph.steps.some((step) => step.status === "failed") || latestMission.steps.some((step) => step.status === "failed") - ? "intervention_required" - : deriveMissionStatusFromRun(graph, latestMission); - if (latestStatus === "intervention_required") { - nextMissionStatus = latestStatus; - if (latestMission.status !== "intervention_required") { - transitionMissionStatus(mission.id, latestStatus, { allowTerminalRestart: true }); - } - } else { - transitionMissionStatus(mission.id, "failed", { - lastError: extractRunFailureMessage(graph) - }); - } - } else { - if (!options?.nextMissionStatus) { - nextMissionStatus = deriveMissionStatusFromRun(graph, missionService.get(mission.id) ?? refreshed); - } - transitionMissionStatus(mission.id, nextMissionStatus); - } - logger.debug("ai_orchestrator.sync_completed", { - missionId: mission.id, - runId, - reason, - runStatus: graph.run.status, - missionStatus: nextMissionStatus - }); - const runCompleted = - graph.run.status === "succeeded" || - graph.run.status === "failed" || - graph.run.status === "canceled"; - if (runCompleted) { - cleanupCoordinatorCheckpointFile(runId, "terminal_status_sync"); - // Emit the "done" runtime bus event for run completion - recordRuntimeEvent({ - runId, - eventType: "done", - eventKey: `done:${runId}`, - payload: { - runStatus: graph.run.status, - missionStatus: nextMissionStatus, - stepsSucceeded: graph.steps.filter((s) => s.status === "succeeded").length, - stepsFailed: graph.steps.filter((s) => s.status === "failed").length, - stepsTotal: graph.steps.length - } - }); - purgeRunMaps(runId); - runRuntimeProfiles.delete(runId); - activeHealthSweepRuns.delete(runId); - for (const [attemptId, state] of workerStates.entries()) { - if (state.runId !== runId) continue; - // Preserve terminal worker states (completed/failed) so that - // callers can still read them after the run finishes. Only - // discard active (working/initializing) entries and always - // clean up the runtime tracker / persisted state. - attemptRuntimeTrackers.delete(attemptId); - deletePersistedAttemptRuntimeState(attemptId); - if (state.state !== "completed" && state.state !== "failed") { - workerStates.delete(attemptId); - } - } - - // Clean up mission-keyed runtime maps when mission reaches a terminal state. - const missionTerminal = - nextMissionStatus === "completed" || - nextMissionStatus === "failed" || - nextMissionStatus === "canceled"; - if (missionTerminal) { - const mid = mission.id; - chatMessages.delete(mid); - activeSteeringDirectives.delete(mid); - activeChatSessions.delete(mid); - chatTurnQueues.delete(mid); - } - } - } catch (error) { - logger.warn("ai_orchestrator.sync_failed", { - runId, - reason, - error: error instanceof Error ? error.message : String(error) - }); - } finally { - syncLocks.delete(runId); - syncInFlight.delete(runId); - resolveSync(); - } - })(); - await executeSync; - }; - - // ── Project Docs Discovery ────────────────────────────────── - const discoverProjectDocs = () => discoverProjectDocsCtx(ctx); - - const startMissionRun = async (args: MissionRunStartArgs): Promise => { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId.length) throw new Error("missionId is required."); - const initialMission = missionService.get(missionId); - if (!initialMission) throw new Error(`Mission not found: ${missionId}`); - const queueClaimToken = toOptionalString(args.queueClaimToken); - // Track whether we acquired the queue claim so we can release it on - // failure. The claim is only meaningful while the mission is still in - // the "queued" status – once transitioned to "in_progress" (line 6816) - // the claim is irrelevant and clearing it is a harmless no-op. - let queueClaimAcquired = false; - if (initialMission.status === "queued") { - const claimed = missionService.beginStart(missionId, queueClaimToken ?? null); - if (!claimed) { - logger.debug("ai_orchestrator.mission_start_claim_skipped", { - missionId, - queueClaimToken, - reason: "queued_claim_not_acquired", - }); - return { - started: null, - mission: missionService.get(missionId), - }; - } - queueClaimAcquired = true; - } else if (queueClaimToken) { - missionService.clearQueueClaim(missionId, queueClaimToken); - } - - const missionGoal = initialMission.prompt || initialMission.title; - - const missionMetadata = getMissionMetadata(missionId); - const launchMetadata = isRecord(missionMetadata.launch) ? missionMetadata.launch : null; - const launchTeamRuntime = isRecord(launchMetadata?.teamRuntime) ? launchMetadata.teamRuntime : null; - const launchAgentRuntime = isRecord(launchMetadata?.agentRuntime) - ? launchMetadata.agentRuntime - : { - allowParallelAgents: true, - allowSubAgents: true, - allowClaudeAgentTeams: true - }; - const launchEmployeeAgentId = - typeof launchMetadata?.employeeAgentId === "string" && launchMetadata.employeeAgentId.trim().length > 0 - ? launchMetadata.employeeAgentId.trim() - : null; - const missionAfterPlanning = missionService.get(missionId); - const missionMetadataAfterPlanning = getMissionMetadata(missionId); - const missionPhaseConfiguration = isRecord(missionMetadataAfterPlanning.phaseConfiguration) - ? missionMetadataAfterPlanning.phaseConfiguration - : null; - const missionLevelSettings = isRecord(missionMetadataAfterPlanning.missionLevelSettings) - ? missionMetadataAfterPlanning.missionLevelSettings - : null; - const plannerPlanMeta = isRecord(missionMetadataAfterPlanning.plannerPlan) - ? missionMetadataAfterPlanning.plannerPlan - : null; - const plannerSummary = plannerPlanMeta && isRecord(plannerPlanMeta.missionSummary) - ? plannerPlanMeta.missionSummary - : null; - const plannerParallelismRaw = Number(plannerSummary?.parallelismCap ?? Number.NaN); - const plannerParallelismCap = Number.isFinite(plannerParallelismRaw) && plannerParallelismRaw > 0 - ? Math.floor(plannerParallelismRaw) - : null; - const launchMaxParallelRaw = Number(launchMetadata?.maxParallelWorkers ?? Number.NaN); - const launchMaxParallel = Number.isFinite(launchMaxParallelRaw) && launchMaxParallelRaw > 0 - ? Math.floor(launchMaxParallelRaw) - : null; - const requestedRunMode = args.runMode === "manual" ? "manual" : "autopilot"; - const requestedExecutorKind: OrchestratorExecutorKind = - args.defaultExecutorKind === "manual" - || args.defaultExecutorKind === "shell" - || args.defaultExecutorKind === "claude" - || args.defaultExecutorKind === "codex" - || args.defaultExecutorKind === "cursor" - || args.defaultExecutorKind === "opencode" - ? args.defaultExecutorKind - : "opencode"; - const autopilotExecutorKind: OrchestratorExecutorKind = - requestedRunMode === "manual" - ? "manual" - : requestedExecutorKind === "manual" - ? "opencode" - : requestedExecutorKind; - const autopilotEnabled = requestedRunMode === "autopilot" && autopilotExecutorKind !== "manual"; - const autopilotOwnerId = String(args.autopilotOwnerId ?? "").trim() || "orchestrator-autopilot"; - const runParallelismCap = Math.max( - 1, - Math.min( - 32, - launchAgentRuntime.allowParallelAgents === false - ? 1 - : plannerParallelismCap ?? launchMaxParallel ?? 4 - ) - ); - const { - userRules, - projectCtx: coordinatorProjectContext, - availableProviders, - phases, - } = gatherCoordinatorContext(missionId, args); - const sortedPhases = [...phases].sort((a, b) => a.position - b.position); - const effectivePhases = sortedPhases; - const initialPhase = effectivePhases[0] ?? null; - const phaseRuntime = initialPhase - ? { - currentPhaseKey: initialPhase.phaseKey, - currentPhaseName: initialPhase.name, - currentPhaseModel: initialPhase.model, - currentPhaseInstructions: initialPhase.instructions, - currentPhaseValidation: initialPhase.validationGate, - currentPhaseBudget: initialPhase.budget ?? {}, - transitionedAt: nowIso(), - transitions: [ - { - fromPhaseKey: null, - fromPhaseName: null, - toPhaseKey: initialPhase.phaseKey, - toPhaseName: initialPhase.name, - at: nowIso(), - reason: "run_initialized" - } - ], - phaseBudgets: { - [initialPhase.phaseKey]: { - enteredAt: nowIso(), - usedTokens: 0, - usedCostUsd: 0 - } - } - } - : undefined; - - let startupStage: MissionLaunchFailureStage = "run_created"; - let started: { run: OrchestratorRun; steps: OrchestratorStep[] } | null = null; - - try { - // ── Create run — just persistence, no planning ── - started = orchestratorService.startRun({ - missionId, - steps: [], - metadata: { - ...(args.metadata ?? {}), - missionGoal, - missionPrompt: initialMission.prompt ?? "", - runMode: requestedRunMode, - maxParallelWorkers: runParallelismCap, - planner: { - source: "mission_planner", - stepCount: missionAfterPlanning?.steps.length ?? 0, - strategy: typeof plannerSummary?.strategy === "string" ? plannerSummary.strategy : null, - parallelismCap: runParallelismCap, - }, - autopilot: { - enabled: autopilotEnabled, - executorKind: autopilotEnabled ? autopilotExecutorKind : "manual", - ownerId: autopilotOwnerId, - parallelismCap: runParallelismCap, - }, - ...(launchEmployeeAgentId ? { employeeAgentId: launchEmployeeAgentId } : {}), - teamRuntime: launchTeamRuntime - ? { - ...launchTeamRuntime, - ...normalizeAgentRuntimeFlags(launchTeamRuntime) - } - : undefined, - agentRuntime: normalizeAgentRuntimeFlags(launchAgentRuntime), - aiFirst: true, - ...(missionLevelSettings ? { missionLevelSettings } : {}), - ...(missionPhaseConfiguration ? { phaseConfiguration: missionPhaseConfiguration } : {}), - phaseOverride: effectivePhases, - phaseProfileId: typeof missionPhaseConfiguration?.profileId === "string" - ? missionPhaseConfiguration.profileId - : null, - ...(phaseRuntime ? { phaseRuntime } : {}), - } - }); - if (!started) { - throw new Error("Mission run failed to start."); - } - const startedRun = started.run; - const startedRunId = startedRun.id; - // Transition mission to in_progress early so that failure paths - // (lane creation, coordinator startup) can move it to intervention_required. - // The queued -> intervention_required transition is not allowed. - // Starting a new run is the one intentional terminal -> active transition: - // completed/failed/canceled missions can be explicitly rerun from the UI. - transitionMissionStatus(missionId, "in_progress", { allowTerminalRestart: true }); - - emitCoordinatorLifecycle({ - missionId, - runId: startedRunId, - state: "booting", - message: "I’m online and getting the run ready.", - }); - - startupStage = "lane_create"; - const missionLaneId = await ensureMissionLaneForRun({ - runId: startedRunId, - missionId, - missionTitle: initialMission.title, - }); - if (!missionLaneId) { - pauseRunWithIntervention({ - runId: startedRunId, - missionId, - source: "transition_decision", - reasonCode: "mission_lane_unavailable", - title: "Mission lane isolation failed", - body: "ADE could not create or recover the dedicated mission lane/worktree for this run. The mission has been paused so work does not proceed in an unsafe lane.", - requestedAction: "Fix lane/worktree health, then resume the run to retry mission activation.", - metadata: { - startupPath: "start_mission_run", - isolationRequired: true, - } - }); - const blockedRun = orchestratorService.listRuns({ limit: 1_000 }).find((entry) => entry.id === startedRunId) ?? startedRun; - return { - started: { - run: blockedRun, - steps: orchestratorService.listSteps(startedRunId), - }, - mission: missionService.get(missionId), - }; - } - - startupStage = "coordinator_start"; - emitCoordinatorLifecycle({ - missionId, - runId: startedRunId, - state: "analyzing_prompt", - message: "I’m reading your prompt and sizing up the work.", - }); - const coordinatorModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); - const coordinatorAgent = startCoordinatorAgentV2(missionId, startedRunId, missionGoal, coordinatorModelConfig, { - userRules, - projectContext: coordinatorProjectContext, - availableProviders, - phases: effectivePhases, - missionLaneId, - }); - if (!coordinatorAgent?.isAlive) { - pauseRunWithIntervention({ - runId: startedRunId, - missionId, - source: "transition_decision", - reasonCode: "coordinator_start_failed", - title: "Coordinator startup failed", - body: "Coordinator runtime did not start successfully. Mission activation and autopilot were blocked to prevent non-autonomous fallback behavior.", - requestedAction: "Resolve coordinator startup health, then resume the run.", - metadata: { - startupPath: "start_mission_run" - } - }); - const failedRun = orchestratorService.listRuns({ limit: 1_000 }).find((entry) => entry.id === startedRunId) ?? startedRun; - return { - started: { - run: failedRun, - steps: orchestratorService.listSteps(startedRunId) - }, - mission: missionService.get(missionId), - }; - } - - startupStage = "run_activate"; - // Persist the mission lane before activation so that any listeners - // of the run_activated event can already resolve the lane ID. - persistMissionLaneIdForRun(startedRunId, missionLaneId); - const activatedRun = orchestratorService.activateRun(startedRunId); - transitionMissionStatus(missionId, "in_progress", { allowTerminalRestart: true }); - if (initialPhase?.phaseKey.trim().toLowerCase() !== "planning") { - emitOrchestratorMessage( - missionId, - "I’m ready and moving into the first task.", - null, - { - role: "coordinator_v2", - runId: startedRunId, - source: "coordinator_lifecycle", - }, - ); - } - - const initialGraph = getRunGraphSafe(started.run.id); - updateMissionStateDoc( - started.run.id, - { - ...(initialGraph ? { updateProgress: buildMissionStateProgressFromGraph(initialGraph) } : {}), - pendingInterventions: pendingInterventionsForMission(missionId), - }, - { graph: initialGraph } - ); - - void syncMissionFromRun(started.run.id, "mission_run_started"); - - return { - started: { - run: activatedRun, - steps: orchestratorService.listSteps(started.run.id), - }, - mission: missionService.get(missionId), - }; - } catch (error) { - if (started?.run.id) { - const launchFailureMetadata = buildMissionLaunchFailureMetadata({ - runId: started.run.id, - failureStage: startupStage, - error, - }); - persistRunLaunchFailureMetadata(started.run.id, launchFailureMetadata); - pauseRunWithIntervention({ - runId: started.run.id, - missionId, - source: "transition_decision", - interventionType: "unrecoverable_error", - reasonCode: MISSION_LAUNCH_FAILURE_REASON_CODE, - title: "Mission launch failed", - body: toMissionLaunchFailureBody({ failureStage: startupStage, error }), - requestedAction: "Review the launch failure details, fix the runtime or configuration issue, then restart the mission run.", - metadata: launchFailureMetadata, - }); - throw toMissionLaunchFailureError(error, launchFailureMetadata); - } - throw error; - } finally { - // If we acquired the queue claim but the mission never left "queued" - // (e.g. an exception before `transitionMissionStatus` or an early - // return), release the claim so another caller can retry. - if (queueClaimAcquired && queueClaimToken) { - const currentMission = missionService.get(missionId); - if (currentMission?.status === "queued") { - try { - missionService.clearQueueClaim(missionId, queueClaimToken); - } catch { - // Best-effort cleanup – don't mask the original error. - } - } - } - } - }; - - /** Gather user rules, project context, and available providers for the coordinator. */ - const gatherCoordinatorContext = (missionId: string, args: MissionRunStartArgs) => { - // Extract user rules from mission metadata / launch config - const missionMeta = getMissionMetadata(missionId); - const launch = isRecord(missionMeta?.launch) ? missionMeta.launch as Record : {}; - - const userRules: CoordinatorUserRules = {}; - if (typeof launch.providerPreference === "string") userRules.providerPreference = launch.providerPreference; - if (typeof launch.costMode === "string") userRules.costMode = launch.costMode; - if (typeof launch.maxParallelWorkers === "number") userRules.maxParallelWorkers = launch.maxParallelWorkers; - const agentRuntime = isRecord(launch.agentRuntime) ? launch.agentRuntime : null; - if (agentRuntime) { - const flags = normalizeAgentRuntimeFlags(agentRuntime as Partial); - userRules.allowParallelAgents = flags.allowParallelAgents; - userRules.allowSubAgents = flags.allowSubAgents; - userRules.allowClaudeAgentTeams = flags.allowClaudeAgentTeams; - if (agentRuntime.allowParallelAgents === false) { - userRules.maxParallelWorkers = 1; - } - } - if (typeof launch.laneStrategy === "string") userRules.laneStrategy = launch.laneStrategy; - if (typeof launch.customInstructions === "string") userRules.customInstructions = launch.customInstructions; - if (args.defaultExecutorKind) userRules.providerPreference = args.defaultExecutorKind; - userRules.closeoutContract = "Promote or assemble one result lane at mission closeout. Use ADE's internal integration lane when multiple worker lanes must be joined. Never create an external GitHub PR automatically."; - - // Read phase-based settings and pass budget/recovery/model fields directly into userRules - const { settings: phaseSettings } = resolveActivePhaseSettings(missionId); - const coordinatorModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); - if (coordinatorModelConfig) userRules.coordinatorModel = modelConfigToServiceModel(coordinatorModelConfig); - if (phaseSettings.recoveryLoop) { - userRules.recoveryEnabled = phaseSettings.recoveryLoop.enabled; - userRules.recoveryMaxIterations = phaseSettings.recoveryLoop.maxIterations; - } - // Pass budget limits if configured - const budgetConfig = isRecord(launch.budgetConfig) ? launch.budgetConfig : null; - if (budgetConfig) { - if (typeof budgetConfig.maxCostUsd === "number") userRules.budgetLimitUsd = budgetConfig.maxCostUsd; - if (typeof budgetConfig.maxTokens === "number") userRules.budgetLimitTokens = budgetConfig.maxTokens; - } - const plannerPlanMeta = isRecord(missionMeta?.plannerPlan) ? missionMeta.plannerPlan : null; - const plannerSummaryHints = (() => { - if (!plannerPlanMeta || !isRecord(plannerPlanMeta.missionSummary)) return [] as string[]; - const summary = plannerPlanMeta.missionSummary as Record; - const objective = typeof summary.objective === "string" ? summary.objective.trim() : ""; - const strategy = typeof summary.strategy === "string" ? summary.strategy.trim() : ""; - const parallelismCap = Number(summary.parallelismCap ?? NaN); - const hints: string[] = []; - if (objective.length > 0) hints.push(`[planner] Objective: ${objective}`); - if (strategy.length > 0) hints.push(`[planner] Strategy: ${strategy}`); - if (Number.isFinite(parallelismCap) && parallelismCap > 0) hints.push(`[planner] Parallelism cap: ${Math.floor(parallelismCap)}`); - return hints; - })(); - - const projectDocsContext = discoverProjectDocs(); - const projectContextHints = plannerSummaryHints.slice(0, 12); - - // Build a shallow file tree for coordinator context - let fileTree: string | undefined; - if (projectRoot) { - try { - const fsSync = require("fs"); - const entries = fsSync.readdirSync(projectRoot, { withFileTypes: true }) as Array<{ isDirectory(): boolean; name: string }>; - const lines = entries - .sort((a: { isDirectory(): boolean; name: string }, b: { isDirectory(): boolean; name: string }) => - a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1, - ) - .slice(0, 50) - .map((e: { isDirectory(): boolean; name: string }) => (e.isDirectory() ? `${e.name}/` : e.name)); - fileTree = lines.join("\n"); - } catch { - /* ignore — best-effort */ - } - } - - const projectCtx: CoordinatorProjectContext | undefined = - projectRoot ? { - projectRoot, - projectDocPaths: projectDocsContext.found ? projectDocsContext.paths : undefined, - projectHints: projectContextHints.length > 0 ? projectContextHints : undefined, - fileTree, - } : undefined; - - // Detect available providers - const availableProviders: CoordinatorAvailableProvider[] = []; - const availability = aiIntegrationService?.getAvailability?.(); - if (availability) { - if (availability.claude.auth.ready) availableProviders.push({ name: "claude", available: true }); - if (availability.codex) availableProviders.push({ name: "codex", available: true }); - } - if (aiIntegrationService) { - availableProviders.push({ - name: "api/local", - available: true, - }); - } - - // Load phase cards so the coordinator knows the mission execution phases - const phaseConfig = missionService.getPhaseConfiguration(missionId); - const phases = phaseConfig?.selectedPhases ?? []; - - return { userRules, projectCtx, availableProviders, phases }; - }; - - const getRunGraphSafe = (runId: string): OrchestratorRunGraph | null => { - try { - return orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - } catch { - return null; - } - }; - - const ensureCoordinatorAgentV2 = (runId: string): { agent: CoordinatorAgent | null; created: boolean } => { - const existingAgent = coordinatorAgents.get(runId) ?? null; - if (existingAgent?.isAlive) { - return { agent: existingAgent, created: false }; - } - if (existingAgent && !existingAgent.isAlive) { - const recoveredAgent = attemptCoordinatorRecovery(runId); - return { agent: recoveredAgent, created: Boolean(recoveredAgent?.isAlive) }; - } - - const graph = getRunGraphSafe(runId); - const runStatus = graph?.run.status ?? null; - if (runStatus && TERMINAL_COORDINATOR_RUN_STATUSES.has(runStatus as OrchestratorRunStatus) && runStatus !== "succeeded") { - return { agent: null, created: false }; - } - - const missionId = graph?.run.missionId ?? getMissionIdForRun(runId); - if (!missionId) return { agent: null, created: false }; - - const mission = missionService.get(missionId); - if (!mission) return { agent: null, created: false }; - - const missionGoal = mission.prompt || mission.title; - const missionLaneId = resolvePersistedMissionLaneIdForRun(runId); - if (missionLaneId) { - persistMissionLaneIdForRun(runId, missionLaneId); - } - const coordinatorModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); - const { userRules, projectCtx, availableProviders, phases } = gatherCoordinatorContext(missionId, { missionId }); - const agent = startCoordinatorAgentV2(missionId, runId, missionGoal, coordinatorModelConfig, { - userRules, - projectContext: projectCtx, - availableProviders, - phases, - skipInitialActivationMessage: true, - missionLaneId: missionLaneId ?? undefined, - }); - if (!agent?.isAlive) { - return { agent: null, created: false }; - } - if (runStatus === "succeeded") { - agent.injectMessage( - [ - "[MISSION TERMINAL CONSULT]", - "The run has already succeeded. Stay in post-run closeout mode: answer questions and record missing closeout evidence only.", - "Do not spawn new implementation work. If the operator provides final risk notes or changed-files context, you may call complete_mission to record the closeout summary.", - ].join("\n"), - ); - } - - logger.info("ai_orchestrator.coordinator_resumed_after_pause", { - runId, - missionId, - runStatus, - }); - - return { agent, created: true }; - }; - - const collectGracefulShutdownTargets = (runId: string): Array<{ - sessionId: string; - attemptId: string | null; - stepId: string | null; - stepKey: string | null; - laneId: string | null; - }> => { - const graph = getRunGraphSafe(runId); - const stepById = new Map(); - const targetsBySession = new Map(); - - if (graph) { - for (const step of graph.steps) { - stepById.set(step.id, step); - } - for (const attempt of graph.attempts) { - if (!ACTIVE_ATTEMPT_STATUSES.has(attempt.status)) continue; - const sessionId = toOptionalString(attempt.executorSessionId); - if (!sessionId || targetsBySession.has(sessionId)) continue; - const step = stepById.get(attempt.stepId); - targetsBySession.set(sessionId, { - sessionId, - attemptId: attempt.id, - stepId: attempt.stepId, - stepKey: step?.stepKey ?? null, - laneId: step?.laneId ?? null - }); - } - } - - for (const [attemptId, state] of workerStates.entries()) { - if (state.runId !== runId) continue; - if (state.state === "completed" || state.state === "failed" || state.state === "disposed") continue; - const sessionId = toOptionalString(state.sessionId); - if (!sessionId || targetsBySession.has(sessionId)) continue; - const step = stepById.get(state.stepId); - targetsBySession.set(sessionId, { - sessionId, - attemptId, - stepId: state.stepId, - stepKey: step?.stepKey ?? null, - laneId: step?.laneId ?? null - }); - } - - return [...targetsBySession.values()]; - }; - - const waitForRunAttemptDrain = async (runId: string, timeoutMs = GRACEFUL_CANCEL_DRAIN_WAIT_MS): Promise => { - const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs)); - while (Date.now() <= deadline) { - const graph = getRunGraphSafe(runId); - if (!graph) return true; - const hasActiveAttempts = graph.attempts.some((attempt) => ACTIVE_ATTEMPT_STATUSES.has(attempt.status)); - if (!hasActiveAttempts) return true; - await new Promise((resolve) => setTimeout(resolve, GRACEFUL_CANCEL_DRAIN_POLL_MS)); - } - return false; - }; - - const cancelRunGracefully = async (cancelArgs: CancelOrchestratorRunArgs): Promise => { - const runId = toOptionalString(cancelArgs.runId); - if (!runId) throw new Error("runId is required."); - - const reason = toOptionalString(cancelArgs.reason) ?? "Run canceled."; - const missionId = getMissionIdForRun(runId); - if (missionId) { - emitCoordinatorLifecycle({ - missionId, - runId, - state: "stopped", - message: "I’ve stopped the run.", - force: true, - }); - } - const evalTimer = pendingCoordinatorEvals.get(runId); - if (evalTimer) { - clearTimeout(evalTimer); - pendingCoordinatorEvals.delete(runId); - } - cancelingRuns.add(runId); - coordinatorWriteBarrierRuns.add(runId); - endCoordinatorAgentV2(runId); - const targets = collectGracefulShutdownTargets(runId); - - logger.info("ai_orchestrator.run_cancel_graceful_start", { - runId, - missionId, - workerCount: targets.length - }); - - const noticeText = `Run cancellation requested: ${reason}\nStop work and wrap up any in-flight operations.`; - let notifiedWorkers = 0; - let interruptedSessions = 0; - let disposedSessions = 0; - - const chatService = agentChatService; - if (chatService) { - if (typeof chatService.sendMessage === "function") { - const outcomes = await Promise.all( - targets.map(async (target) => ({ - sessionId: target.sessionId, - outcome: await runBestEffortWithTimeout({ - timeoutMs: GRACEFUL_CANCEL_NOTIFY_TIMEOUT_MS, - work: () => sendWorkerMessageToSession(target.sessionId, noticeText) - }) - })) - ); - for (const entry of outcomes) { - if (entry.outcome.ok) { - notifiedWorkers += 1; - } else { - logger.debug("ai_orchestrator.run_cancel_graceful_notify_failed", { - runId, - sessionId: entry.sessionId, - timedOut: entry.outcome.timedOut, - error: entry.outcome.error - }); - } - } - } - - if (typeof chatService.interrupt === "function") { - const outcomes = await Promise.all( - targets.map(async (target) => ({ - sessionId: target.sessionId, - outcome: await runBestEffortWithTimeout({ - timeoutMs: GRACEFUL_CANCEL_INTERRUPT_TIMEOUT_MS, - work: () => chatService.interrupt({ sessionId: target.sessionId }) - }) - })) - ); - for (const entry of outcomes) { - if (entry.outcome.ok) { - interruptedSessions += 1; - } else { - logger.debug("ai_orchestrator.run_cancel_graceful_interrupt_failed", { - runId, - sessionId: entry.sessionId, - timedOut: entry.outcome.timedOut, - error: entry.outcome.error - }); - } - } - } - - if (typeof chatService.dispose === "function") { - const outcomes = await Promise.all( - targets.map(async (target) => ({ - sessionId: target.sessionId, - outcome: await runBestEffortWithTimeout({ - timeoutMs: GRACEFUL_CANCEL_DISPOSE_TIMEOUT_MS, - work: () => chatService.dispose({ sessionId: target.sessionId }) - }) - })) - ); - for (const entry of outcomes) { - if (entry.outcome.ok) { - disposedSessions += 1; - } else { - logger.debug("ai_orchestrator.run_cancel_graceful_dispose_failed", { - runId, - sessionId: entry.sessionId, - timedOut: entry.outcome.timedOut, - error: entry.outcome.error - }); - } - } - } - } - - const drained = await waitForRunAttemptDrain(runId, GRACEFUL_CANCEL_DRAIN_WAIT_MS); - if (!drained) { - logger.debug("ai_orchestrator.run_cancel_graceful_drain_timeout", { - runId, - timeoutMs: GRACEFUL_CANCEL_DRAIN_WAIT_MS - }); - } - - orchestratorService.cancelRun({ runId, reason }); - purgeRunMaps(runId); - if (missionId) { - transitionMissionStatus(missionId, "canceled", { - lastError: reason, - }); - } - await syncMissionFromRun(runId, "graceful_cancel", { nextMissionStatus: "canceled" }); - - const run = orchestratorService.listRuns({ limit: 1_000 }).find((entry) => entry.id === runId); - if (!run) throw new Error(`Run not found after cancellation: ${runId}`); - - logger.info("ai_orchestrator.run_cancel_graceful_complete", { - runId, - missionId, - workerCount: targets.length, - notifiedWorkers, - interruptedSessions, - disposedSessions, - drainedBeforeForceCancel: drained - }); - - bestEffortCascadeCleanup(missionId, runId, "run_cancel"); - cancelingRuns.delete(runId); - - return run; - }; - - const bestEffortCascadeCleanup = (missionId: string | null | undefined, runId: string, logPrefix: string): void => { - if (!missionId) return; - try { - void cleanupTeamResources({ missionId, runId, cleanupLanes: true }).then((result) => { - logger.info(`ai_orchestrator.${logPrefix}_cascade_cleanup_complete`, { - runId, - missionId, - lanesArchived: result.lanesArchived.length, - lanesSkipped: result.lanesSkipped.length, - laneErrors: result.laneErrors.length - }); - }).catch((err) => { - logger.debug(`ai_orchestrator.${logPrefix}_cascade_cleanup_failed`, { - runId, - missionId, - error: err instanceof Error ? err.message : String(err) - }); - }); - } catch { - // Cleanup must never break the caller - } - }; - - const cleanupTeamResources = async ( - cleanupArgs: CleanupOrchestratorTeamResourcesArgs - ): Promise => { - const missionId = toOptionalString(cleanupArgs.missionId); - if (!missionId) throw new Error("missionId is required."); - const mission = missionService.get(missionId); - if (!mission) throw new Error(`Mission not found: ${missionId}`); - - const requestedRunId = toOptionalString(cleanupArgs.runId); - const cleanupLanes = cleanupArgs.cleanupLanes !== false; - let resolvedRunId: string | null = requestedRunId ?? null; - const laneIds = new Set(); - - if (requestedRunId) { - const runMissionId = getMissionIdForRun(requestedRunId); - if (!runMissionId) { - throw new Error(`Run not found: ${requestedRunId}`); - } - if (runMissionId !== missionId) { - throw new Error(`Run ${requestedRunId} does not belong to mission ${missionId}.`); - } - const graph = getRunGraphSafe(requestedRunId); - for (const laneId of graph?.steps.map((step) => toOptionalString(step.laneId)).filter((value): value is string => Boolean(value)) ?? []) { - laneIds.add(laneId); - } - const missionLaneId = resolvePersistedMissionLaneIdForRun(requestedRunId); - if (missionLaneId) laneIds.add(missionLaneId); - } else { - const missionRuns = [...orchestratorService.listRuns({ missionId, limit: 200 })] - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); - resolvedRunId = missionRuns[0]?.id ?? null; - for (const run of missionRuns) { - const graph = getRunGraphSafe(run.id); - for (const laneId of graph?.steps.map((step) => toOptionalString(step.laneId)).filter((value): value is string => Boolean(value)) ?? []) { - laneIds.add(laneId); - } - const missionLaneId = resolvePersistedMissionLaneIdForRun(run.id); - if (missionLaneId) laneIds.add(missionLaneId); - } - } - const laneIdList = [...laneIds]; - - const result: CleanupOrchestratorTeamResourcesResult = { - missionId, - runId: resolvedRunId, - laneIds: laneIdList, - lanesArchived: [], - lanesSkipped: [], - laneErrors: [] - }; - - if (!cleanupLanes || !laneIdList.length) { - return result; - } - - if (!laneService || typeof laneService.archive !== "function") { - result.laneErrors = laneIdList.map((laneId) => ({ laneId, error: "Lane service unavailable." })); - return result; - } - - for (const laneId of laneIdList) { - try { - await Promise.resolve(laneService.archive({ laneId })); - result.lanesArchived.push(laneId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const normalized = message.toLowerCase(); - if ( - (normalized.includes("already") && normalized.includes("archived")) - || normalized.includes("not found") - ) { - result.lanesSkipped.push(laneId); - } else { - result.laneErrors.push({ laneId, error: message }); - } - } - } - - logger.info("ai_orchestrator.team_resources_cleanup_complete", { - missionId, - runId: resolvedRunId, - laneCount: laneIds.size, - archivedCount: result.lanesArchived.length, - skippedCount: result.lanesSkipped.length, - errorCount: result.laneErrors.length - }); - - return result; - }; - - const resumeRun = (args: { runId: string }): OrchestratorRun => { - const runId = toOptionalString(args.runId); - if (!runId) throw new Error("runId is required."); - - const currentRun = getRunGraphSafe(runId)?.run ?? null; - if (currentRun?.status === "completing") { - const finalized = finalizeRun({ runId }); - if (finalized.finalized) { - void syncMissionFromRun(runId, "resume_completing_finalized"); - } - return getRunGraphSafe(runId)?.run ?? currentRun; - } - if ( - currentRun?.status === "succeeded" - || currentRun?.status === "failed" - || currentRun?.status === "canceled" - ) { - void syncMissionFromRun(runId, "resume_terminal_sync"); - return currentRun; - } - - const shouldEnsureCoordinator = - currentRun?.status === "paused" - || currentRun?.status === "active" - || currentRun?.status === "bootstrapping" - || currentRun?.status === "queued"; - const ensuredCoordinator = shouldEnsureCoordinator ? ensureCoordinatorAgentV2(runId) : { agent: null, created: false }; - - if (shouldEnsureCoordinator && !ensuredCoordinator.agent?.isAlive) { - throw new Error("Coordinator runtime did not start successfully. Run remains paused."); - } - - try { - const resumedRun = orchestratorService.resumeRun({ runId }); - const coordinatorAgent = ensuredCoordinator.agent; - if (ensuredCoordinator.created && coordinatorAgent) { - const resumedGraph = getRunGraphSafe(runId); - const stepSummaries = resumedGraph?.steps.map((step) => ` - ${step.stepKey} (${step.title}): ${step.status}`).join("\n") ?? ""; - coordinatorAgent.injectMessage( - [ - "[RUN RESUMED]", - "You were restarted after this run was paused and the prior coordinator session was intentionally shut down.", - "Call read_mission_status immediately, review any open interventions or failed attempts, and continue from the current DAG state without redoing completed work.", - "", - "Current steps:", - stepSummaries || " (none)", - ].join("\n"), - ); - emitMilestoneReadinessToCoordinator({ runId, reason: "run_resumed" }); - - const missionId = resumedRun.missionId ?? currentRun?.missionId ?? getMissionIdForRun(runId); - if (missionId) { - emitCoordinatorLifecycle({ - missionId, - runId, - state: "booting", - message: "I’m back online and resuming the run.", - force: true, - }); - } - } - return resumedRun; - } catch (error) { - if (ensuredCoordinator.created) { - endCoordinatorAgentV2(runId); - } - throw error; - } - }; - - const steerMission = (steerArgs: SteerMissionArgs): SteerMissionResult => { - const missionId = steerArgs.missionId?.trim(); - if (!missionId) throw new Error("missionId is required."); - const targetedInterventionId = - typeof steerArgs.interventionId === "string" && steerArgs.interventionId.trim().length > 0 - ? steerArgs.interventionId.trim() - : null; - - const mission = missionService.get(missionId); - if (!mission) throw new Error(`Mission not found: ${missionId}`); - - const directive: UserSteeringDirective = { - missionId, - directive: steerArgs.directive, - priority: steerArgs.priority ?? "suggestion", - targetStepKey: steerArgs.targetStepKey ?? null - }; - const resolutionKind = steerArgs.resolutionKind ?? (targetedInterventionId ? "answer_provided" : null); - const missionRuns = (() => { - try { - return orchestratorService.listRuns({ missionId, limit: 200 }); - } catch { - return []; - } - })(); - const missionRunIds = new Set(missionRuns.map((run) => run.id)); - const runGraphById = new Map(); - const loadRunGraph = (runId: string): OrchestratorRunGraph | null => { - if (runGraphById.has(runId)) { - return runGraphById.get(runId) ?? null; - } - try { - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - runGraphById.set(runId, graph); - return graph; - } catch { - runGraphById.set(runId, null); - return null; - } - }; - const applyPhaseApprovalTransition = (args: { - interventionId: string; - interventionType: string; - interventionMetadata: Record | null; - runId: string; - }): boolean => { - if (args.interventionType !== "phase_approval") return false; - if (resolutionKind === "cancel_run" || resolutionKind === "skip_question") return false; - const sourcePhaseKey = typeof args.interventionMetadata?.phaseKey === "string" - ? args.interventionMetadata.phaseKey.trim() - : ""; - const targetPhaseKey = typeof args.interventionMetadata?.targetPhaseKey === "string" - ? args.interventionMetadata.targetPhaseKey.trim() - : ""; - if (!args.runId || !targetPhaseKey) return false; - - const graph = loadRunGraph(args.runId); - if (!graph) return false; - const metadata = { ...(isRecord(graph.run.metadata) ? graph.run.metadata : {}) }; - const phaseRuntimeSource = asRecord(metadata.phaseRuntime); - const phaseRuntime: Record = phaseRuntimeSource ? { ...phaseRuntimeSource } : {}; - const currentPhaseKey = typeof phaseRuntime.currentPhaseKey === "string" - ? phaseRuntime.currentPhaseKey.trim() - : ""; - if (!sourcePhaseKey || !currentPhaseKey || currentPhaseKey !== sourcePhaseKey) { - logger.debug("ai_orchestrator.phase_approval_stale", { - missionId, - runId: args.runId, - interventionId: args.interventionId, - sourcePhaseKey, - currentPhaseKey, - targetPhaseKey, - }); - return false; - } - - const targetPhase = resolvePhaseCardsFromRunMetadata(metadata) - .find((phase) => phase.phaseKey === targetPhaseKey); - if (!targetPhase) return false; - const previousPhaseKey = typeof phaseRuntime.currentPhaseKey === "string" - ? phaseRuntime.currentPhaseKey - : null; - const previousPhaseName = typeof phaseRuntime.currentPhaseName === "string" - ? phaseRuntime.currentPhaseName - : null; - if (previousPhaseKey === targetPhase.phaseKey && previousPhaseName === targetPhase.name) { - return true; - } - - const transitionedAt = nowIso(); - const transitions = Array.isArray(phaseRuntime.transitions) ? [...phaseRuntime.transitions] : []; - const transitionReason = "phase_approval_resolved"; - transitions.unshift({ - fromPhaseKey: previousPhaseKey, - fromPhaseName: previousPhaseName, - toPhaseKey: targetPhase.phaseKey, - toPhaseName: targetPhase.name, - at: transitionedAt, - reason: transitionReason, - }); - const existingPhaseBudgets = asRecord(phaseRuntime.phaseBudgets) ?? {}; - const targetPhaseBudget = asRecord(existingPhaseBudgets[targetPhase.phaseKey]); - phaseRuntime.transitions = transitions.slice(0, 64); - phaseRuntime.currentPhaseKey = targetPhase.phaseKey; - phaseRuntime.currentPhaseName = targetPhase.name; - phaseRuntime.currentPhaseModel = targetPhase.model; - phaseRuntime.currentPhaseInstructions = targetPhase.instructions; - phaseRuntime.currentPhaseValidation = targetPhase.validationGate; - phaseRuntime.currentPhaseBudget = targetPhase.budget ?? {}; - phaseRuntime.transitionedAt = transitionedAt; - phaseRuntime.phaseBudgets = { - ...existingPhaseBudgets, - [targetPhase.phaseKey]: { - enteredAt: typeof targetPhaseBudget?.enteredAt === "string" && targetPhaseBudget.enteredAt.trim().length > 0 - ? targetPhaseBudget.enteredAt - : transitionedAt, - usedTokens: Number.isFinite(Number(targetPhaseBudget?.usedTokens)) - ? Number(targetPhaseBudget?.usedTokens) - : 0, - usedCostUsd: Number.isFinite(Number(targetPhaseBudget?.usedCostUsd)) - ? Number(targetPhaseBudget?.usedCostUsd) - : 0, - }, - }; - db.run( - ` - update orchestrator_runs - set metadata_json = json_set(coalesce(nullif(metadata_json, ''), '{}'), '$.phaseRuntime', json(?)), - updated_at = ? - where id = ? - and project_id = ? - `, - [JSON.stringify(phaseRuntime), transitionedAt, args.runId, graph.run.projectId], - ); - orchestratorService.appendTimelineEvent({ - runId: args.runId, - eventType: "phase_transition", - reason: transitionReason, - detail: { - fromPhaseKey: previousPhaseKey, - fromPhaseName: previousPhaseName, - toPhaseKey: targetPhase.phaseKey, - toPhaseName: targetPhase.name, - phaseModel: targetPhase.model, - phaseValidation: targetPhase.validationGate, - phaseBudget: targetPhase.budget ?? {}, - transitionedAt, - source: "phase_approval_resolution", - interventionId: args.interventionId, - }, - }); - recordRuntimeEvent({ - runId: args.runId, - eventType: "progress", - eventKey: `phase_approval_transition:${args.interventionId}`, - payload: { - transition: "phase_approval_resolved", - interventionId: args.interventionId, - fromPhaseKey: previousPhaseKey, - toPhaseKey: targetPhase.phaseKey, - }, - }); - orchestratorService.emitRuntimeUpdate({ - runId: args.runId, - reason: "phase_transition", - }); - runGraphById.delete(args.runId); - return true; - }; - const isLiveSteeringWorker = (attemptId: string, ws: OrchestratorWorkerState): boolean => { - if (!ws.runId || ws.state !== "working" || !ws.sessionId) return false; - if (!missionRunIds.has(ws.runId)) return false; - const graph = loadRunGraph(ws.runId); - if (!graph) return false; - const attempt = graph.attempts.find((entry) => entry.id === attemptId); - if (!attempt || attempt.status !== "running") return false; - return true; - }; - - const existing = activeSteeringDirectives.get(missionId) ?? loadSteeringDirectivesFromMetadata(missionId); - existing.push(directive); - activeSteeringDirectives.set(missionId, existing.slice(-MAX_PERSISTED_STEERING_DIRECTIVES)); - - // Persist to mission metadata - try { - updateMissionMetadata(missionId, (meta) => { - const storedDirectives = Array.isArray(meta[STEERING_DIRECTIVES_METADATA_KEY]) - ? meta[STEERING_DIRECTIVES_METADATA_KEY] as unknown[] - : []; - storedDirectives.push({ - ...directive, - appliedAt: nowIso() - }); - meta[STEERING_DIRECTIVES_METADATA_KEY] = storedDirectives.slice(-MAX_PERSISTED_STEERING_DIRECTIVES); - }); - } catch (error) { - logger.debug("ai_orchestrator.steer_persist_failed", { - missionId, - error: error instanceof Error ? error.message : String(error) - }); - } - - logger.info("ai_orchestrator.mission_steered", { - missionId, - priority: directive.priority, - targetStepKey: directive.targetStepKey ?? "all", - directivePreview: directive.directive.slice(0, 100) - }); - - const projectedStepCount = projectSteeringDirectiveToActiveSteps(directive); - const steeringAppliedAt = nowIso(); - const steeringRunTargets = missionRuns.filter( - (run) => - run.status === "active" - || run.status === "bootstrapping" - || run.status === "queued" - || run.status === "paused", - ); - for (const run of steeringRunTargets) { - const steeringPayload = { - directive: clipTextForContext(directive.directive, 360), - priority: directive.priority, - targetStepKey: directive.targetStepKey ?? null, - interventionId: targetedInterventionId, - projectedStepCount, - appliedAt: steeringAppliedAt, - }; - recordRuntimeEvent({ - runId: run.id, - eventType: "coordinator_steering", - eventKey: `coordinator_steering:${run.id}:${steeringAppliedAt}:${directive.targetStepKey ?? "all"}`, - occurredAt: steeringAppliedAt, - payload: steeringPayload, - }); - try { - orchestratorService.appendTimelineEvent({ - runId: run.id, - eventType: "coordinator_steering", - reason: clipTextForContext(directive.directive, 180), - detail: steeringPayload, - }); - } catch (error) { - logger.debug("ai_orchestrator.steering_timeline_append_failed", { - missionId, - runId: run.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - const resolvedInterventions: string[] = []; - let resolvedNonManualInterventionCount = 0; - const resumedRunIds = new Set(); - const refreshedMission = missionService.get(missionId); - const targetedOpenIntervention = targetedInterventionId - ? refreshedMission?.interventions.find((entry) => entry.status === "open" && entry.id === targetedInterventionId) ?? null - : null; - const openManualInput = targetedInterventionId - ? targetedOpenIntervention?.interventionType === "manual_input" ? [targetedOpenIntervention] : [] - : []; - const targetedPhaseApprovalMeta = targetedOpenIntervention?.interventionType === "phase_approval" && isRecord(targetedOpenIntervention.metadata) - ? targetedOpenIntervention.metadata - : null; - const targetedPhaseApprovalRunId = typeof targetedPhaseApprovalMeta?.runId === "string" - ? targetedPhaseApprovalMeta.runId.trim() - : ""; - const stalePhaseApprovalSiblings = - targetedOpenIntervention?.interventionType === "phase_approval" - && resolutionKind !== "cancel_run" - && resolutionKind !== "skip_question" - && targetedPhaseApprovalRunId.length > 0 - ? (refreshedMission?.interventions ?? []).filter((entry) => { - if (entry.id === targetedOpenIntervention.id) return false; - if (entry.status !== "open" || entry.interventionType !== "phase_approval") return false; - if (entry.createdAt > targetedOpenIntervention.createdAt) return false; - const metadata = isRecord(entry.metadata) ? entry.metadata : null; - const runId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - return runId === targetedPhaseApprovalRunId; - }) - : []; - const openNonManualInterventions = targetedOpenIntervention && targetedOpenIntervention.interventionType !== "manual_input" - ? [targetedOpenIntervention, ...stalePhaseApprovalSiblings] - : []; - - for (const intervention of openNonManualInterventions) { - try { - const meta = isRecord(intervention.metadata) ? intervention.metadata : null; - const runId = typeof meta?.runId === "string" ? meta.runId.trim() : ""; - const stepId = typeof meta?.stepId === "string" ? meta.stepId.trim() : ""; - const attemptId = typeof meta?.attemptId === "string" ? meta.attemptId.trim() : ""; - let appliedPhaseTransition = false; - let phaseApprovalStale = false; - let interventionResolutionKind: MissionInterventionResolutionKind = resolutionKind ?? "answer_provided"; - let interventionResolutionNote = - resolutionKind === "accept_defaults" - ? "Resolved by accepting defaults." - : resolutionKind === "skip_question" - ? "Resolved by dismissing the intervention." - : resolutionKind === "cancel_run" - ? "Resolved by canceling the run." - : `Resolved by operator directive (${directive.priority}).`; - if ( - intervention.interventionType === "phase_approval" - && resolutionKind !== "cancel_run" - && resolutionKind !== "skip_question" - ) { - appliedPhaseTransition = runId.length > 0 - ? applyPhaseApprovalTransition({ - interventionId: intervention.id, - interventionType: intervention.interventionType, - interventionMetadata: meta, - runId, - }) - : false; - if (!appliedPhaseTransition) { - phaseApprovalStale = true; - interventionResolutionKind = "stale_phase_approval"; - interventionResolutionNote = - "Resolved as stale because this phase approval no longer matches the run's current phase; no phase transition was applied."; - } - } - missionService.resolveIntervention({ - missionId, - interventionId: intervention.id, - status: "resolved", - resolutionKind: interventionResolutionKind, - note: interventionResolutionNote, - }); - resolvedInterventions.push(intervention.id); - resolvedNonManualInterventionCount += 1; - if (runId.length > 0) { - recordRuntimeEvent({ - runId, - stepId: stepId || null, - attemptId: attemptId || null, - sessionId: typeof meta?.sessionId === "string" ? meta.sessionId : null, - eventType: "intervention_resolved", - eventKey: `intervention_resolved:${intervention.id}:${directive.priority}`, - payload: { - interventionId: intervention.id, - reason: phaseApprovalStale - ? "stale_phase_approval" - : resolutionKind === "cancel_run" ? "cancel_run" : "steering_directive", - priority: directive.priority, - directive: clipTextForContext(directive.directive, 220), - resolutionKind: interventionResolutionKind, - appliedPhaseTransition, - }, - }); - if (resolutionKind === "cancel_run") { - void cancelRunGracefully({ - runId, - reason: "User canceled the run from an intervention.", - }).catch((error) => { - logger.warn("ai_orchestrator.cancel_run_from_intervention_failed", { - missionId, - runId, - error: getErrorMessage(error), - }); - }); - } else { - resumedRunIds.add(runId); - } - } - } catch (error) { - logger.debug("ai_orchestrator.steer_resolve_intervention_failed", { - missionId, - interventionId: intervention.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - for (const intervention of openManualInput) { - try { - const meta = isRecord(intervention.metadata) ? intervention.metadata : null; - const runId = typeof meta?.runId === "string" ? meta.runId.trim() : ""; - const attemptId = typeof meta?.attemptId === "string" ? meta.attemptId.trim() : ""; - const stepId = typeof meta?.stepId === "string" ? meta.stepId.trim() : ""; - const _sessionId = typeof meta?.sessionId === "string" ? meta.sessionId.trim() : ""; - const ownerGraph = runId ? loadRunGraph(runId) : null; - const ownerAttempt = attemptId.length > 0 ? ownerGraph?.attempts.find((entry) => entry.id === attemptId) ?? null : null; - const ownerStep = stepId.length > 0 ? ownerGraph?.steps.find((entry) => entry.id === stepId) ?? null : null; - const ownerStepMeta = isRecord(ownerStep?.metadata) ? ownerStep.metadata : null; - const reasonCode = typeof meta?.reasonCode === "string" ? meta.reasonCode.trim() : ""; - const ownerPlanningLike = - String(ownerStepMeta?.stepType ?? "").trim().toLowerCase() === "planning" - || String(ownerStepMeta?.stepType ?? "").trim().toLowerCase() === "analysis" - || String(ownerStepMeta?.phaseKey ?? "").trim().toLowerCase() === "planning"; - const plannerNaturalQuestionRetry = - reasonCode === "planner_natural_question" || reasonCode === "planner_required_question_missing"; - const ownerCanResume = - attemptId.length === 0 - || Boolean(ownerAttempt && ownerAttempt.status === "running") - || plannerNaturalQuestionRetry; - - if (resolutionKind !== "cancel_run" && !ownerCanResume) { - const failureTitle = ownerPlanningLike ? "Planner can no longer continue" : "Question owner is no longer active"; - const failureBody = ownerPlanningLike - ? "The planner that asked this question has already failed or exited, so ADE cannot continue planning with that same thread." - : "The worker that opened this question is no longer active, so ADE cannot safely resume it from this answer."; - missionService.addIntervention({ - missionId, - interventionType: "failed_step", - title: failureTitle, - body: failureBody, - requestedAction: ownerPlanningLike - ? "Review the planner failure and explicitly choose whether to retry planning." - : "Review the worker failure and choose whether to retry or replace that step.", - metadata: { - runId: runId || null, - stepId: stepId || null, - stepKey: ownerStep?.stepKey ?? null, - attemptId: attemptId || null, - reasonCode: ownerPlanningLike ? "planner_cannot_resume" : "question_owner_inactive", - }, - }); - continue; - } - - missionService.resolveIntervention({ - missionId, - interventionId: intervention.id, - status: "resolved", - resolutionKind: resolutionKind ?? "answer_provided", - note: - resolutionKind === "accept_defaults" - ? "Resolved by accepting defaults." - : resolutionKind === "skip_question" - ? "Resolved by skipping the question." - : resolutionKind === "cancel_run" - ? "Resolved by canceling the run." - : `Resolved by user answer (${directive.priority}).` - }); - resolvedInterventions.push(intervention.id); - if (runId.length > 0) { - const threadId = typeof meta?.threadId === "string" ? meta.threadId.trim() : ""; - const replyTo = typeof meta?.messageId === "string" ? meta.messageId.trim() : ""; - const questionReplyLink = - resolutionKind !== "cancel_run" && threadId.length > 0 && replyTo.length > 0 - ? buildQuestionReplyLink({ - threadId, - replyTo, - interventionId: intervention.id, - directive: directive.directive - }) - : null; - recordRuntimeEvent({ - runId, - stepId: typeof meta?.stepId === "string" ? meta.stepId : null, - attemptId: typeof meta?.attemptId === "string" ? meta.attemptId : null, - sessionId: typeof meta?.sessionId === "string" ? meta.sessionId : null, - eventType: "intervention_resolved", - eventKey: questionReplyLink?.messageId ?? `intervention_resolved:${intervention.id}:${directive.priority}`, - payload: { - interventionId: intervention.id, - reason: resolutionKind === "cancel_run" ? "cancel_run" : "steering_directive", - priority: directive.priority, - directive: clipTextForContext(directive.directive, 220), - resolutionKind, - threadId: questionReplyLink?.threadId ?? null, - messageId: questionReplyLink?.messageId ?? null, - replyTo: questionReplyLink?.replyTo ?? null - } - }); - if (questionReplyLink) { - recordRuntimeEvent({ - runId, - stepId: typeof meta?.stepId === "string" ? meta.stepId : null, - attemptId: typeof meta?.attemptId === "string" ? meta.attemptId : null, - sessionId: typeof meta?.sessionId === "string" ? meta.sessionId : null, - eventType: "progress", - eventKey: `question_resumed:${questionReplyLink.messageId}`, - payload: { - transition: "question_answered_resume", - source: "steering_directive", - threadId: questionReplyLink.threadId, - messageId: questionReplyLink.messageId, - replyTo: questionReplyLink.replyTo - } - }); - } - if ( - plannerNaturalQuestionRetry - && resolutionKind !== "cancel_run" - && ownerGraph - && ownerStep - && stepId.length > 0 - ) { - const answeredAt = nowIso(); - const nextStepMetadata: Record = { - ...(isRecord(ownerStep.metadata) ? ownerStep.metadata : {}), - lastAnsweredPlannerQuestion: { - interventionId: intervention.id, - question: typeof meta?.question === "string" ? meta.question : intervention.body, - answer: directive.directive, - answeredAt, - }, - }; - delete nextStepMetadata.awaitingUserInput; - delete nextStepMetadata.blockedAt; - delete nextStepMetadata.blockedByAttemptId; - delete nextStepMetadata.blockedErrorClass; - delete nextStepMetadata.blockedErrorMessage; - delete nextStepMetadata.blockedSticky; - db.run( - ` - update orchestrator_steps - set status = 'ready', - metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(nextStepMetadata), answeredAt, stepId, runId, ownerGraph.run.projectId], - ); - const runMetadata = { ...(isRecord(ownerGraph.run.metadata) ? ownerGraph.run.metadata : {}) }; - delete runMetadata.pendingPlannerQuestion; - db.run( - ` - update orchestrator_runs - set metadata_json = ?, - last_error = null, - updated_at = ? - where id = ? - and project_id = ? - `, - [JSON.stringify(runMetadata), answeredAt, runId, ownerGraph.run.projectId], - ); - orchestratorService.appendTimelineEvent({ - runId, - stepId, - attemptId: attemptId || null, - eventType: "step_status_changed", - reason: "planner_question_answered", - detail: { - from: ownerStep.status, - to: "ready", - interventionId: intervention.id, - reasonCode, - }, - }); - recordRuntimeEvent({ - runId, - stepId, - attemptId: attemptId || null, - sessionId: typeof meta?.sessionId === "string" ? meta.sessionId : null, - eventType: "progress", - eventKey: `planner_question_answered:${intervention.id}`, - payload: { - transition: "planner_question_answered_retry_ready", - interventionId: intervention.id, - }, - }); - } - if ( - resolutionKind !== "cancel_run" - && attemptId.length > 0 - && stepId.length > 0 - && ownerAttempt?.status === "running" - ) { - const existingWorker = workerStates.get(attemptId); - const executorKindRaw = typeof meta?.executorKind === "string" ? meta.executorKind : null; - const executorKind: OrchestratorExecutorKind = - existingWorker?.executorKind - ?? (executorKindRaw === "claude" - || executorKindRaw === "codex" - || executorKindRaw === "cursor" - || executorKindRaw === "opencode" - || executorKindRaw === "shell" - || executorKindRaw === "manual" - ? executorKindRaw - : "manual"); - upsertWorkerState(attemptId, { - runId, - stepId, - sessionId: typeof meta?.sessionId === "string" ? meta.sessionId : null, - executorKind, - state: "working" - }); - const tracker = ensureAttemptRuntimeTracker(attemptId); - if (questionReplyLink) { - tracker.lastQuestionThreadId = questionReplyLink.threadId; - tracker.lastQuestionMessageId = questionReplyLink.messageId; - } - } - if (resolutionKind === "cancel_run") { - void cancelRunGracefully({ - runId, - reason: "User canceled the run from an intervention.", - }).catch((error) => { - logger.warn("ai_orchestrator.cancel_run_from_intervention_failed", { - missionId, - runId, - error: getErrorMessage(error), - }); - }); - } else { - resumedRunIds.add(runId); - } - } - } catch (error) { - logger.debug("ai_orchestrator.steer_resolve_intervention_failed", { - missionId, - interventionId: intervention.id, - error: error instanceof Error ? error.message : String(error) - }); - } - } - if (resolvedInterventions.length > 0) { - emitOrchestratorMessage( - missionId, - resolutionKind === "cancel_run" - ? `Resolved ${resolvedInterventions.length} intervention${resolvedInterventions.length === 1 ? "" : "s"} and started canceling the run.` - : resolutionKind === "accept_defaults" - ? `Accepted defaults for ${resolvedInterventions.length} intervention${resolvedInterventions.length === 1 ? "" : "s"} and resumed the owning worker.` - : resolutionKind === "skip_question" - ? `Skipped ${resolvedInterventions.length} question${resolvedInterventions.length === 1 ? "" : "s"} and resumed the owning worker.` - : resolvedNonManualInterventionCount > 0 - ? `Applied operator direction and resolved ${resolvedInterventions.length} intervention${resolvedInterventions.length === 1 ? "" : "s"}.` - : `Applied user answers and resolved ${resolvedInterventions.length} waiting-input intervention${resolvedInterventions.length === 1 ? "" : "s"}.`, - directive.targetStepKey ?? null, - { - interventionIds: resolvedInterventions, - projectedStepCount - } - ); - - // VAL-STEER-001: After resolving all blocking interventions, check if any - // paused runs should be resumed. If no open interventions remain on the - // mission, resume paused runs and transition mission back to in_progress. - try { - const postSteerMission = missionService.get(missionId); - const remainingOpenInterventions = postSteerMission?.interventions.filter( - (iv) => iv.status === "open" - ) ?? []; - if (remainingOpenInterventions.length === 0) { - for (const runId of resumedRunIds) { - try { - const runGraph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - if (runGraph.run.status === "paused") { - resumeRun({ runId }); - logger.info("ai_orchestrator.steer_auto_resumed_run", { missionId, runId }); - } - } catch (resumeError) { - logger.debug("ai_orchestrator.steer_auto_resume_failed", { - missionId, - runId, - error: resumeError instanceof Error ? resumeError.message : String(resumeError), - }); - } - } - // Transition mission back to in_progress if it was intervention_required - if (postSteerMission?.status === "intervention_required") { - try { - missionService.update({ missionId, status: "in_progress" }); - } catch { - // Best effort — mission might already be in a different state - } - } - } - } catch (steerResumeError) { - logger.debug("ai_orchestrator.steer_resume_check_failed", { - missionId, - error: steerResumeError instanceof Error ? steerResumeError.message : String(steerResumeError), - }); - } - } - - // Real-time delivery: try to deliver the directive to running worker sessions - if (agentChatService) { - const stepIdToStepKey = new Map(); - for (const run of missionRuns) { - const graph = loadRunGraph(run.id); - if (!graph) continue; - for (const step of graph.steps) { - stepIdToStepKey.set(step.id, step.stepKey); - } - } - - const formattedDirective = `[ADE ORCHESTRATOR STEERING]: ${directive.directive}`; - - if (directive.targetStepKey) { - // Targeted delivery: find the specific worker for this step - const targetWorker = [...workerStates.entries()].find(([attemptId, ws]) => { - if (!isLiveSteeringWorker(attemptId, ws)) return false; - const workerStepKey = stepIdToStepKey.get(ws.stepId); - return workerStepKey === directive.targetStepKey; - }); - - if (targetWorker) { - const [targetAttemptId, targetWs] = targetWorker; - void (async () => { - try { - await sendWorkerMessageToSession(targetWs.sessionId!, formattedDirective); - logger.info("ai_orchestrator.steering_delivered_targeted", { - missionId, - targetStepKey: directive.targetStepKey, - attemptId: targetAttemptId, - sessionId: targetWs.sessionId - }); - } catch (deliveryError) { - logger.debug("ai_orchestrator.steering_worker_delivery_failed", { - missionId, - attemptId: targetAttemptId, - error: deliveryError instanceof Error ? deliveryError.message : String(deliveryError) - }); - } - })(); - } else { - // Worker not running yet — directive stays queued in metadata for when it starts - logger.info("ai_orchestrator.steering_queued_for_step", { - missionId, - targetStepKey: directive.targetStepKey - }); - } - } else { - // No target — deliver to ALL running workers for this mission - for (const [attemptId, ws] of workerStates.entries()) { - if (isLiveSteeringWorker(attemptId, ws)) { - void (async () => { - try { - await sendWorkerMessageToSession(ws.sessionId!, formattedDirective); - logger.debug("ai_orchestrator.steering_delivered_to_worker", { - missionId, - attemptId, - sessionId: ws.sessionId, - directive: directive.directive.slice(0, 200) - }); - } catch (deliveryError) { - logger.debug("ai_orchestrator.steering_worker_delivery_failed", { - missionId, - attemptId, - error: deliveryError instanceof Error ? deliveryError.message : String(deliveryError) - }); - } - })(); - } - } - } - } - - // Event-driven coordinator trigger: evaluate plan immediately after steering directive - try { - const steerRuns = orchestratorService.listRuns({ missionId }); - for (const steerRun of steerRuns) { - if (steerRun.status === "active" || steerRun.status === "bootstrapping" || steerRun.status === "queued") { - void orchestratorService - .startReadyAutopilotAttempts({ - runId: steerRun.id, - reason: "steering_directive", - }) - .catch((error: unknown) => { - logger.debug("ai_orchestrator.steering_autopilot_start_failed", { - missionId, - runId: steerRun.id, - error: error instanceof Error ? error.message : String(error), - }); - }); - } - if (steerRun.status === "active" || steerRun.status === "bootstrapping" || steerRun.status === "paused") { - triggerCoordinatorEvaluation(steerRun.id, "steering_directive"); - } - } - } catch { - // Non-critical — coordinator will still pick it up on next heartbeat - } - - return { - acknowledged: true, - appliedAt: nowIso(), - response: `Directive accepted (${directive.priority}). Applied to ${projectedStepCount} active step${projectedStepCount === 1 ? "" : "s"} and will guide upcoming worker runs.` - }; - }; - - const createContextCheckpoint = (args: Parameters[1]) => - createContextCheckpointCtx(ctx, args); - - // normalizeDeliveryError, isBusyDeliveryError, isNoActiveTurnError, computeWorkerRetryBackoffMs - // are imported directly from chatMessageService (pure functions, no ctx needed) - - const workerDeliveryDeps: WorkerDeliveryDeps = { - appendChatMessage, - recordRuntimeEvent - }; - - const resolveWorkerDeliveryContext = (message: OrchestratorChatMessage): WorkerDeliveryContext | null => - resolveWorkerDeliveryContextCtx(ctx, message); - - const persistThreadWorkerLinks = (context: WorkerDeliveryContext) => - persistThreadWorkerLinksCtx(ctx, context); - - const sendWorkerMessageToSession = (sessionId: string, text: string) => - sendWorkerMessageToSessionCtx(ctx, sessionId, text); - - const replayQueuedWorkerMessages = (args: { - reason: string; - missionId?: string | null; - threadId?: string | null; - sessionId?: string | null; - }) => replayQueuedWorkerMessagesCtx(ctx, args, workerDeliveryDeps); - - const routeMessageToCoordinator = (message: OrchestratorChatMessage) => - routeMessageToCoordinatorCtx(ctx, message, { - ...workerDeliveryDeps, - steerMission, - enqueueChatResponse, - runHealthSweep - }); - - const routeMessageToWorker = (message: OrchestratorChatMessage) => - routeMessageToWorkerCtx(ctx, message, workerDeliveryDeps); - - const listChatThreads = (threadArgs: ListOrchestratorChatThreadsArgs) => - listChatThreadsCtx(ctx, threadArgs); - - const getThreadMessages = (threadArgs: GetOrchestratorThreadMessagesArgs) => - getThreadMessagesCtx(ctx, threadArgs); - - const getChat = (chatArgs: GetOrchestratorChatArgs) => - getChatCtx(ctx, chatArgs); - - const deliverMessageToAgent = (args: Parameters[1]) => - deliverMessageToAgentCtx(ctx, args, { sendWorkerMessageToSession }); - - const getGlobalChat = (args: GetGlobalChatArgs) => - getGlobalChatCtx(ctx, args); - - const getActiveAgents = (args: GetActiveAgentsArgs) => - getActiveAgentsCtx(ctx, args); - - const sendAgentMessageWithMentions = (agentMsgArgs: SendAgentMessageArgs) => - sendAgentMessageWithMentionsCtx(ctx, agentMsgArgs, { deliverMessageToAgent }); - - const maybeForwardSubagentCompletionRollup = (args: { - event: OrchestratorRuntimeEvent; - graph: OrchestratorRunGraph; - }): void => { - const { event, graph } = args; - if (!event.attemptId) return; - if (event.reason !== "attempt_completed" && event.reason !== "completed") return; - const dedupeKey = `${event.runId}:${event.attemptId}`; - if (subagentCompletionRollupSent.has(dedupeKey)) return; - - const attempt = graph.attempts.find((candidate) => candidate.id === event.attemptId); - if (!attempt) return; - if (attempt.status !== "succeeded" && attempt.status !== "failed") return; - - const childStep = graph.steps.find((step) => step.id === attempt.stepId); - const childMeta = isRecord(childStep?.metadata) ? childStep.metadata : null; - if (!childStep || childMeta?.isSubAgent !== true) return; - const parentWorkerId = typeof childMeta.parentWorkerId === "string" ? childMeta.parentWorkerId.trim() : ""; - if (!parentWorkerId.length) return; - - const parentStep = graph.steps.find((step) => step.stepKey === parentWorkerId); - if (!parentStep) return; - const parentAttempts = graph.attempts.filter((candidate) => candidate.stepId === parentStep.id); - if (!parentAttempts.length) return; - const runningParentAttempt = parentAttempts.find((candidate) => candidate.status === "running"); - const parentAttempt = runningParentAttempt - ?? [...parentAttempts].sort((left, right) => { - const leftTs = Date.parse(left.completedAt ?? left.createdAt); - const rightTs = Date.parse(right.completedAt ?? right.createdAt); - return rightTs - leftTs; - })[0]; - if (!parentAttempt?.id || parentAttempt.id === attempt.id) return; - - const childLabel = childStep.title?.trim().length ? childStep.title.trim() : childStep.stepKey; - const summary = attempt.resultEnvelope?.summary?.trim() || attempt.errorMessage?.trim() || "No summary provided."; - const content = `Sub-agent '${childLabel}' completed (${attempt.status}): ${summary}`; - - try { - sendAgentMessageWithMentions({ - missionId: graph.run.missionId, - fromAttemptId: attempt.id, - toAttemptId: parentAttempt.id, - content, - metadata: { - source: "subagent_result_rollup", - parentWorkerId, - childWorkerId: childStep.stepKey, - childStepId: childStep.id, - childAttemptId: attempt.id, - } - }); - subagentCompletionRollupSent.add(dedupeKey); - } catch (error) { - logger.debug("ai_orchestrator.subagent_completion_rollup_failed", { - runId: event.runId, - childAttemptId: attempt.id, - parentAttemptId: parentAttempt.id, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - - const listWorkerDigests = (digestArgs: ListOrchestratorWorkerDigestsArgs) => listWorkerDigestsCtx(ctx, digestArgs); - const getWorkerDigest = (digestArgs: GetOrchestratorWorkerDigestArgs) => getWorkerDigestCtx(ctx, digestArgs); - const getContextCheckpoint = (checkpointArgs: GetOrchestratorContextCheckpointArgs) => getContextCheckpointCtx(ctx, checkpointArgs); - const listLaneDecisions = (laneArgs: ListOrchestratorLaneDecisionsArgs) => listLaneDecisionsCtx(ctx, laneArgs); - const listArtifacts = (artifactArgs: ListOrchestratorArtifactsArgs): OrchestratorArtifact[] => { - const missionId = toOptionalString(artifactArgs.missionId); - const runId = toOptionalString(artifactArgs.runId); - const stepId = toOptionalString(artifactArgs.stepId); - - if (stepId) { - return orchestratorService.getArtifactsForStep(stepId).filter((artifact) => { - if (missionId && artifact.missionId !== missionId) return false; - if (runId && artifact.runId !== runId) return false; - return true; - }); - } - - const resolvedMissionId = - missionId - ?? (runId - ? toOptionalString(orchestratorService.getRunGraph({ runId, timelineLimit: 0 }).run.missionId) - : null); - if (!resolvedMissionId) { - throw new Error("listArtifacts requires missionId, runId, or stepId."); - } - - return orchestratorService.getArtifactsForMission(resolvedMissionId).filter((artifact) => - !runId || artifact.runId === runId - ); - }; - const listWorkerCheckpoints = (checkpointArgs: ListOrchestratorWorkerCheckpointsArgs): OrchestratorWorkerCheckpoint[] => { - const missionId = toOptionalString(checkpointArgs.missionId); - const runId = toOptionalString(checkpointArgs.runId); - const stepId = toOptionalString(checkpointArgs.stepId); - - let checkpoints: OrchestratorWorkerCheckpoint[] = []; - if (runId) { - checkpoints = orchestratorService.getWorkerCheckpointsForRun({ runId }); - } else if (missionId) { - checkpoints = orchestratorService.getWorkerCheckpointsForMission({ missionId }); - } else if (stepId) { - const row = db.get<{ run_id: string | null }>( - `select run_id from orchestrator_steps where id = ? limit 1`, - [stepId], - ); - const resolvedRunId = toOptionalString(row?.run_id); - if (!resolvedRunId) throw new Error(`Unable to resolve run for step ${stepId}.`); - checkpoints = orchestratorService.getWorkerCheckpointsForRun({ runId: resolvedRunId }); - } else { - throw new Error("listWorkerCheckpoints requires missionId, runId, or stepId."); - } - - return stepId ? checkpoints.filter((checkpoint) => checkpoint.stepId === stepId) : checkpoints; - }; - const getPromptInspector = (promptArgs: GetOrchestratorPromptInspectorArgs): OrchestratorPromptInspector => { - const runId = String(promptArgs.runId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 500 }); - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : null; - const phaseRuntime = isRecord(runMeta?.phaseRuntime) ? runMeta.phaseRuntime : null; - - if (promptArgs.target === "worker") { - const stepId = toOptionalString(promptArgs.stepId); - if (!stepId) throw new Error("stepId is required for worker prompt inspection."); - return buildWorkerPromptInspector({ graph, stepId }); - } - - const missionId = graph.run.missionId; - const missionGoal = - typeof runMeta?.missionGoal === "string" && runMeta.missionGoal.trim().length > 0 - ? runMeta.missionGoal.trim() - : missionService.get(missionId)?.prompt ?? ""; - const coordinatorContext = gatherCoordinatorContext(missionId, { missionId }); - return buildCoordinatorPromptInspector({ - runId, - missionId, - missionGoal, - userRules: coordinatorContext.userRules, - projectContext: coordinatorContext.projectCtx, - availableProviders: coordinatorContext.availableProviders, - phases: coordinatorContext.phases, - currentPhaseKey: toOptionalString(phaseRuntime?.currentPhaseKey), - currentPhaseName: toOptionalString(phaseRuntime?.currentPhaseName), - }); - }; - const getPlanningPromptPreview = (promptArgs: GetPlanningPromptPreviewArgs): OrchestratorPromptInspector => - buildPlanningPromptPreview(promptArgs); - - const chatRoutingDeps: ChatRoutingDeps = { - routeMessageToWorker, - routeMessageToCoordinator, - replayQueuedWorkerMessages, - sendWorkerMessageToSession, - createContextCheckpoint, - listWorkerDigests, - }; - - const sendThreadMessage = (threadArgs: SendOrchestratorThreadMessageArgs) => - sendThreadMessageCtx(ctx, threadArgs, chatRoutingDeps); - - const sendChat = (chatArgs: SendOrchestratorChatArgs) => - sendChatCtx(ctx, chatArgs, chatRoutingDeps); - - const setMissionMetricsConfig = (configArgs: SetMissionMetricsConfigArgs): MissionMetricsConfig => - setMissionMetricsConfigCtx(ctx, configArgs); - - const getMissionMetrics = (metricArgs: GetMissionMetricsArgs): { - config: MissionMetricsConfig | null; - samples: MissionMetricSample[]; - } => getMissionMetricsCtx(ctx, metricArgs); - - const reconciliationDeps: ReconciliationDeps = { - resolveWorkerDeliveryContext, - persistThreadWorkerLinks, - replayQueuedWorkerMessages, - }; - - const reconcileThreadedMessagingState = () => - reconcileThreadedMessagingStateCtx(ctx, reconciliationDeps); - - const startHealthSweepLoop = () => { - if (disposed || healthSweepTimer) return; - healthSweepTimer = setInterval(() => { - if (disposed) return; - if ( - !hasRecoverableWorkerRuntimeState(workerStates.values()) - && activeHealthSweepRuns.size === 0 - && !hasRecoverableRuntimeWork() - ) { - return; - } - void runHealthSweep("interval").catch((error) => { - logger.debug("ai_orchestrator.health_sweep_interval_failed", { - error: error instanceof Error ? error.message : String(error) - }); - }); - }, HEALTH_SWEEP_INTERVAL_MS); - healthSweepTimerRef.current = healthSweepTimer; - if (!disposed && hasRecoverableRuntimeWork()) { - void runHealthSweep("startup").catch((error) => { - logger.debug("ai_orchestrator.health_sweep_startup_failed", { - error: error instanceof Error ? error.message : String(error) - }); - }); - } - }; - - const onSessionRuntimeSignal = (signal: SessionRuntimeSignal): void => { - if (disposed) return; - const sessionId = String(signal.sessionId ?? "").trim(); - if (!sessionId.length) return; - const normalizedSignal: SessionRuntimeSignal = { - laneId: String(signal.laneId ?? "").trim(), - sessionId, - runtimeState: parseTerminalRuntimeState(signal.runtimeState) ?? "running", - lastOutputPreview: - typeof signal.lastOutputPreview === "string" && signal.lastOutputPreview.trim().length > 0 - ? clipTextForContext(signal.lastOutputPreview.trim(), MAX_RUNTIME_SIGNAL_PREVIEW_CHARS) - : null, - at: typeof signal.at === "string" && signal.at.trim().length > 0 ? signal.at : nowIso() - }; - onSessionRuntimeSignalCtx(ctx, normalizedSignal, { - processSessionRuntimeSignal, - replayQueuedWorkerMessages, - }); - if (sessionRuntimeSignals.size > 200) { - pruneSessionRuntimeSignals(); - } - }; - - const COORDINATOR_MANUAL_CHAT_TURNLESS_KEY = "__turnless__"; - const MAX_COORDINATOR_MANUAL_CHAT_REPLAY_BUFFERS = 80; - - const agentChatTurnKey = (event: AgentChatEvent): string => { - const turnId = "turnId" in event && typeof event.turnId === "string" - ? event.turnId.trim() - : ""; - return turnId || COORDINATOR_MANUAL_CHAT_TURNLESS_KEY; - }; - - const pruneCoordinatorManualChatReplayBuffers = (): void => { - let total = 0; - for (const buffers of coordinatorManualChatReplayBuffers.values()) total += buffers.size; - if (total <= MAX_COORDINATOR_MANUAL_CHAT_REPLAY_BUFFERS) return; - const entries = [...coordinatorManualChatReplayBuffers.entries()] - .flatMap(([sessionId, buffers]) => - [...buffers.entries()].map(([turnKey, buffer]) => ({ sessionId, turnKey, updatedAtMs: buffer.updatedAtMs })) - ) - .sort((a, b) => a.updatedAtMs - b.updatedAtMs); - for (const entry of entries.slice(0, Math.max(0, total - MAX_COORDINATOR_MANUAL_CHAT_REPLAY_BUFFERS))) { - const buffers = coordinatorManualChatReplayBuffers.get(entry.sessionId); - buffers?.delete(entry.turnKey); - if (buffers && buffers.size === 0) coordinatorManualChatReplayBuffers.delete(entry.sessionId); - } - }; - - const maybeReplayManualCoordinatorChatToolCalls = ( - coordinatorChat: { missionId: string; runId: string; laneId: string | null }, - envelope: AgentChatEventEnvelope, - ): void => { - const sessionId = envelope.sessionId.trim(); - if (!sessionId || coordinatorChatRuntimeOwnedSessions.has(sessionId)) return; - const event = envelope.event; - const turnKey = agentChatTurnKey(event); - - if (event.type === "text") { - const text = event.text; - if (!text.length) return; - const buffers = coordinatorManualChatReplayBuffers.get(sessionId) ?? new Map(); - const current = buffers.get(turnKey) ?? { text: "", updatedAtMs: 0, replayStarted: false }; - if (!current.replayStarted) { - current.text += text; - current.updatedAtMs = Date.now(); - buffers.set(turnKey, current); - coordinatorManualChatReplayBuffers.set(sessionId, buffers); - pruneCoordinatorManualChatReplayBuffers(); - } - return; - } - - if (event.type !== "done" || event.status !== "completed") return; - - const buffers = coordinatorManualChatReplayBuffers.get(sessionId); - if (!buffers || buffers.size === 0) return; - const resolvedTurnKey = - turnKey !== COORDINATOR_MANUAL_CHAT_TURNLESS_KEY - ? turnKey - : buffers.size === 1 - ? [...buffers.keys()][0] ?? COORDINATOR_MANUAL_CHAT_TURNLESS_KEY - : COORDINATOR_MANUAL_CHAT_TURNLESS_KEY; - const buffer = buffers.get(resolvedTurnKey); - if (!buffer || buffer.replayStarted) return; - const assistantText = buffer.text.trim(); - if (!assistantText.length) { - buffers.delete(resolvedTurnKey); - if (buffers.size === 0) coordinatorManualChatReplayBuffers.delete(sessionId); - return; - } - - const existingCoordinator = coordinatorAgents.get(coordinatorChat.runId) ?? null; - const coordinator = existingCoordinator?.isAlive - ? existingCoordinator - : ensureCoordinatorAgentV2(coordinatorChat.runId).agent; - if (!coordinator?.isAlive) { - logger.warn("ai_orchestrator.coordinator_manual_chat_tool_replay_no_runtime", { - missionId: coordinatorChat.missionId, - runId: coordinatorChat.runId, - sessionId, - }); - return; - } - buffer.replayStarted = true; - const fallbackReplayKey = envelope.sequence ?? (Date.parse(envelope.timestamp) || Date.now()); - const replayTurnId = resolvedTurnKey === COORDINATOR_MANUAL_CHAT_TURNLESS_KEY - ? `manual-chat:${sessionId}:${fallbackReplayKey}` - : `manual-chat:${resolvedTurnKey}`; - void coordinator.replayRecoveredMissionControlText(assistantText, replayTurnId) - .then((executedCount) => { - buffers.delete(resolvedTurnKey); - if (buffers.size === 0) coordinatorManualChatReplayBuffers.delete(sessionId); - if (executedCount > 0) { - logger.info("ai_orchestrator.coordinator_manual_chat_tools_replayed", { - missionId: coordinatorChat.missionId, - runId: coordinatorChat.runId, - sessionId, - turnId: replayTurnId, - executedCount, - }); - } - }) - .catch((error: unknown) => { - buffer.replayStarted = false; - logger.warn("ai_orchestrator.coordinator_manual_chat_tool_replay_failed", { - missionId: coordinatorChat.missionId, - runId: coordinatorChat.runId, - sessionId, - turnId: replayTurnId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }; - - const resolvePersistedCoordinatorChatSession = (sessionId: string): { missionId: string; runId: string; laneId: string | null } | null => { - const normalizedSessionId = sessionId.trim(); - if (!normalizedSessionId) return null; - const existing = coordinatorChatSessions.get(normalizedSessionId); - if (existing) return existing; - try { - const row = db.get<{ mission_id: string; run_id: string | null; lane_id: string | null }>( - ` - select mission_id, run_id, lane_id - from orchestrator_chat_threads - where thread_type = 'coordinator' - and session_id = ? - order by updated_at desc - limit 1 - `, - [normalizedSessionId] - ); - const missionId = typeof row?.mission_id === "string" ? row.mission_id.trim() : ""; - const runId = typeof row?.run_id === "string" ? row.run_id.trim() : ""; - if (!missionId || !runId) return null; - const hydrated = { - missionId, - runId, - laneId: typeof row?.lane_id === "string" && row.lane_id.trim().length > 0 ? row.lane_id.trim() : null, - }; - coordinatorChatSessions.set(normalizedSessionId, hydrated); - coordinatorChatSessionByRunId.set(runId, normalizedSessionId); - return hydrated; - } catch (error) { - logger.debug("ai_orchestrator.coordinator_chat_session_hydrate_failed", { - sessionId: normalizedSessionId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }; - - const onAgentChatEvent = (envelope: AgentChatEventEnvelope): void => { - if (disposed) return; - const coordinatorChat = resolvePersistedCoordinatorChatSession(envelope.sessionId); - if (coordinatorChat) { - structuredChatSessions.add(envelope.sessionId); - persistStructuredCoordinatorChatEvent({ - missionId: coordinatorChat.missionId, - runId: coordinatorChat.runId, - laneId: coordinatorChat.laneId, - sessionId: envelope.sessionId, - timestamp: envelope.timestamp, - event: envelope.event, - }); - maybeReplayManualCoordinatorChatToolCalls(coordinatorChat, envelope); - } else { - persistStructuredWorkerChatEvent(envelope); - } - onAgentChatEventCtx(ctx, envelope, { replayQueuedWorkerMessages }); - }; - - const RUN_VIEW_NOISE_EVENT_TYPES = new Set([ - "scheduler_tick", - "claim_heartbeat", - "autopilot_parallelism_cap_adjusted", - "context_snapshot_created", - "context_pack_v2_metrics", - "executor_session_attached", - "startup_verification_warning", - "step_metadata_updated", - "step_dependencies_resolved", - ]); - - const toRunViewSeverity = (value: string | null | undefined): MissionRunViewSeverity => { - const normalized = String(value ?? "").trim().toLowerCase(); - if ( - normalized.includes("fail") - || normalized.includes("error") - || normalized.includes("unavailable") - || normalized.includes("blocked") - || normalized.includes("cancel") - ) { - return "error"; - } - if ( - normalized.includes("warn") - || normalized.includes("pause") - || normalized.includes("waiting") - || normalized.includes("retry") - || normalized.includes("review") - ) { - return "warning"; - } - if (normalized.includes("success") || normalized.includes("complete") || normalized.includes("done")) { - return "success"; - } - return "info"; - }; - - const ACTIVE_RUN_VIEW_STATUSES = new Set(["active", "bootstrapping", "queued", "paused"]); - - const pickPreferredMissionRun = ( - runs: OrchestratorRun[], - requestedRunId?: string | null, - ): OrchestratorRun | null => { - const explicitRunId = toOptionalString(requestedRunId); - if (explicitRunId) { - return runs.find((entry) => entry.id === explicitRunId) ?? null; - } - return runs.find((entry) => ACTIVE_RUN_VIEW_STATUSES.has(entry.status)) ?? runs[0] ?? null; - }; - - const getScopedOpenIntervention = (args: { - mission: MissionDetail; - run: OrchestratorRun | null; - }) => { - const interventionPriority = (entry: MissionDetail["interventions"][number]): number => { - const metadata = isRecord(entry.metadata) ? entry.metadata : null; - if (isMissionLaunchFailureMetadata(metadata, args.run?.id ?? null)) return 200; - const reasonCode = typeof metadata?.reasonCode === "string" ? metadata.reasonCode.trim() : ""; - if (reasonCode === "coordinator_unavailable" || reasonCode === "coordinator_recovery_failed") return 100; - return 0; - }; - const sortByPriority = (left: MissionDetail["interventions"][number], right: MissionDetail["interventions"][number]) => { - const priorityDelta = interventionPriority(right) - interventionPriority(left); - if (priorityDelta !== 0) return priorityDelta; - return Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt); - }; - const openEntries = args.mission.interventions.filter((entry) => entry.status === "open"); - if (!args.run) return [...openEntries].sort(sortByPriority)[0] ?? null; - const runScoped = openEntries - .filter((entry) => toOptionalString(asRecord(entry.metadata)?.runId) === args.run?.id) - .sort(sortByPriority)[0] ?? null; - if (runScoped) return runScoped; - if (args.mission.status === "intervention_required") { - return [...openEntries] - .filter((entry) => !toOptionalString(asRecord(entry.metadata)?.runId)) - .sort(sortByPriority)[0] ?? null; - } - return null; - }; - - const formatInterventionHaltDetail = (intervention: MissionDetail["interventions"][number]): string => { - const metadata = isRecord(intervention.metadata) ? intervention.metadata : null; - if (isMissionLaunchFailureMetadata(metadata)) { - const stage = typeof metadata?.failureStageLabel === "string" - ? metadata.failureStageLabel.trim() - : typeof metadata?.failureStage === "string" - ? metadata.failureStage.trim() - : "launch"; - const rootError = typeof metadata?.rootError === "string" ? metadata.rootError.trim() : ""; - if (rootError.length > 0) { - return `Launch failed during ${stage}: ${rootError}`; - } - } - return intervention.requestedAction?.trim() || intervention.body; - }; - - const toRunViewLatestIntervention = (mission: MissionDetail): MissionRunViewLatestIntervention | null => { - const latest = [...mission.interventions].sort((left, right) => { - const leftAt = Date.parse(left.updatedAt || left.createdAt); - const rightAt = Date.parse(right.updatedAt || right.createdAt); - return (Number.isFinite(rightAt) ? rightAt : 0) - (Number.isFinite(leftAt) ? leftAt : 0); - })[0]; - if (!latest) return null; - const metadata = asRecord(latest.metadata); - return { - id: latest.id, - title: latest.title, - body: latest.body, - interventionType: latest.interventionType, - status: latest.status, - requestedAction: latest.requestedAction ?? null, - ownerLabel: toOptionalString(metadata?.questionOwnerLabel), - createdAt: latest.updatedAt || latest.createdAt, - }; - }; - - const toRunViewWorkerStatus = ( - worker: OrchestratorWorkerState | null, - attemptStatus: string | null, - ): MissionRunViewWorkerSummary["status"] => { - if (!worker) { - if (attemptStatus === "blocked") return "blocked"; - if (attemptStatus === "failed") return "failed"; - if (attemptStatus === "succeeded" || attemptStatus === "canceled") return "completed"; - return "idle"; - } - switch (worker.state) { - case "spawned": - case "initializing": - case "working": - case "waiting_input": - return "active"; - case "failed": - return "failed"; - case "completed": - return "completed"; - case "disposed": - return attemptStatus === "failed" ? "failed" : attemptStatus === "blocked" ? "blocked" : "idle"; - default: - return "idle"; - } - }; - - const buildRunViewHaltReason = (args: { - mission: MissionDetail; - runStatus: OrchestratorRunStatus | null; - coordinatorAvailability: MissionCoordinatorAvailability | null; - latestIntervention: MissionRunViewLatestIntervention | null; - openIntervention: MissionDetail["interventions"][number] | null; - }): MissionRunViewHaltReason | null => { - const openIntervention = args.openIntervention; - if (openIntervention) { - const interventionMetadata = isRecord(openIntervention.metadata) ? openIntervention.metadata : null; - return { - source: "intervention", - title: openIntervention.title, - detail: formatInterventionHaltDetail(openIntervention), - severity: isMissionLaunchFailureMetadata(interventionMetadata) ? "error" : "warning", - interventionId: openIntervention.id, - createdAt: openIntervention.updatedAt || openIntervention.createdAt, - }; - } - if (args.runStatus === "failed") { - return { - source: "run", - title: "Run failed", - detail: args.mission.lastError?.trim() || "The run stopped with a failure.", - severity: "error", - createdAt: args.mission.updatedAt, - }; - } - if (args.runStatus === "canceled" || args.mission.status === "canceled") { - return { - source: args.runStatus === "canceled" ? "run" : "mission", - title: "Run canceled", - detail: args.mission.lastError?.trim() || "Execution was canceled before completion.", - severity: "warning", - createdAt: args.mission.updatedAt, - }; - } - if (args.coordinatorAvailability && args.coordinatorAvailability.available === false) { - return { - source: "coordinator", - title: "Coordinator unavailable", - detail: args.coordinatorAvailability.detail?.trim() || args.coordinatorAvailability.summary, - severity: "error", - createdAt: args.coordinatorAvailability.updatedAt, - }; - } - if (args.mission.status === "failed") { - return { - source: "mission", - title: "Mission failed", - detail: args.mission.lastError?.trim() || args.latestIntervention?.body || "Mission ended in a failed state.", - severity: "error", - createdAt: args.mission.updatedAt, - }; - } - return null; - }; - - const collectRunViewStringList = (value: unknown, limit = 12): string[] => { - if (!Array.isArray(value)) return []; - return value - .map((entry) => String(entry ?? "").trim()) - .filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index) - .slice(0, limit); - }; - - const extractWorkerSummaryFileList = (value: string): { summary: string; filesChanged: string[] } => { - const filesChanged: string[] = []; - const cleaned = value.replace( - /(?:\*{0,2}Changed Files\*{0,2}:?\s*)?```(?:json)?\s*([\s\S]*?)```/gi, - (match, body: string) => { - try { - const parsed = JSON.parse(body.trim()); - const files = collectRunViewStringList(isRecord(parsed) ? parsed.filesChanged ?? parsed.files_changed : null); - if (files.length === 0) return match; - filesChanged.push(...files); - return ""; - } catch { - return match; - } - }, - ).replace(/\s{2,}/g, " ").trim(); - return { - summary: cleaned, - filesChanged: filesChanged.filter((entry, index, all) => all.indexOf(entry) === index), - }; - }; - - const formatRunViewWorkerResultDetail = ( - detailRecord: Record | null, - fallback: string | null, - ): string | null => { - if (!detailRecord) return fallback; - const summaryRaw = toOptionalString(detailRecord.summary) - ?? toOptionalString(detailRecord.message) - ?? fallback - ?? null; - const summaryExtraction = summaryRaw ? extractWorkerSummaryFileList(summaryRaw) : { summary: "", filesChanged: [] }; - const summary = summaryExtraction.summary || summaryRaw; - const filesChanged = [ - ...summaryExtraction.filesChanged, - ...collectRunViewStringList(detailRecord.filesChanged), - ...collectRunViewStringList(detailRecord.changedFiles), - ...collectRunViewStringList(detailRecord.modifiedFiles), - ...collectRunViewStringList(detailRecord.files_changed), - ...collectRunViewStringList(detailRecord.changed_files), - ...collectRunViewStringList(detailRecord.modified_files), - ].filter((entry, index, all) => all.indexOf(entry) === index).slice(0, 12); - const testsRun = isRecord(detailRecord.testsRun) - ? detailRecord.testsRun - : isRecord(detailRecord.tests_run) - ? detailRecord.tests_run - : null; - const parts: string[] = []; - - if (summary) parts.push(summary); - if (filesChanged.length > 0) { - parts.push(`Changed files:\n- ${filesChanged.join("\n- ")}`); - } - if (testsRun) { - const command = toOptionalString(testsRun.command); - const raw = toOptionalString(testsRun.raw); - const counts = [ - Number.isFinite(Number(testsRun.passed)) ? `${Number(testsRun.passed)} passed` : null, - Number.isFinite(Number(testsRun.failed)) ? `${Number(testsRun.failed)} failed` : null, - Number.isFinite(Number(testsRun.skipped)) ? `${Number(testsRun.skipped)} skipped` : null, - ].filter((entry): entry is string => Boolean(entry)); - const countSummary = counts.join(", "); - const testLines = [ - command ? `- ${command}` : null, - countSummary.length > 0 ? `- ${countSummary}` : null, - raw && raw !== command && raw !== countSummary ? `- ${raw}` : null, - ].filter((entry): entry is string => Boolean(entry)); - if (testLines.length > 0) parts.push(`Tests:\n${testLines.join("\n")}`); - } - - return parts.length > 0 ? parts.join("\n\n") : fallback; - }; - - const buildRunViewTimelineItem = ( - event: OrchestratorRunGraph["timeline"][number], - stepById: Map, - ): MissionRunViewProgressItem | null => { - if (RUN_VIEW_NOISE_EVENT_TYPES.has(event.eventType)) return null; - const step = event.stepId ? stepById.get(event.stepId) ?? null : null; - const stepLabel = step?.title ?? step?.stepKey ?? "run"; - const detailRecord = isRecord(event.detail) ? event.detail : null; - const detailSummary = toOptionalString(detailRecord?.summary) - ?? toOptionalString(detailRecord?.message) - ?? toOptionalString(detailRecord?.reason) - ?? null; - - switch (event.eventType) { - case "coordinator_status": { - const state = toOptionalString(detailRecord?.state) ?? event.reason; - const message = detailSummary ?? "The orchestrator updated its status."; - const title = state === "booting" - ? "Orchestrator online" - : state === "analyzing_prompt" - ? "Orchestrator is sizing up the run" - : state === "fetching_project_context" - ? "Orchestrator is gathering project context" - : state === "launching_planner" - ? "Planning worker is launching" - : state === "waiting_on_planner" - ? "Planning worker is in flight" - : state === "running_phase" - ? "Coordinator is running a mission phase" - : state === "planner_launch_failed" - ? "Planning worker launch failed" - : state === "stopped" - ? "Orchestrator offline" - : "Orchestrator update"; - const audience = state === "launching_planner" || state === "waiting_on_planner" - ? "mission_feed" - : "timeline"; - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "system", - title, - detail: message, - severity: toRunViewSeverity(state), - audience, - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - case "phase_transition": - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "system", - title: "Phase advanced", - detail: detailSummary - ?? `Now working in ${(toOptionalString(detailRecord?.toPhaseName) ?? toOptionalString(detailRecord?.toPhaseKey) ?? "the next phase")}.`, - severity: "info", - audience: "mission_feed", - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - case "step_status_changed": { - if (!["running", "blocked", "failed", "succeeded", "canceled"].includes(event.reason)) return null; - const reasonTitle = event.reason === "running" - ? "Worker started" - : event.reason === "succeeded" - ? "Worker completed" - : event.reason === "blocked" - ? "Worker blocked" - : event.reason === "failed" - ? "Worker failed" - : "Worker canceled"; - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "worker", - title: reasonTitle, - detail: detailSummary ?? `${stepLabel} is ${event.reason}.`, - severity: toRunViewSeverity(event.reason), - audience: "mission_feed", - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - case "worker_status_reported": - case "worker_result_reported": - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "worker", - title: event.eventType === "worker_result_reported" ? "Worker result" : "Worker update", - detail: event.eventType === "worker_result_reported" - ? formatRunViewWorkerResultDetail(detailRecord, detailSummary ?? `${stepLabel} reported progress.`) ?? `${stepLabel} reported progress.` - : detailSummary ?? `${stepLabel} reported progress.`, - severity: toRunViewSeverity(detailSummary ?? event.reason), - audience: "mission_feed", - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - case "autopilot_step_skipped": - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "system", - title: "Step skipped", - detail: detailSummary ?? `${stepLabel} was skipped by the orchestrator.`, - severity: "warning", - audience: "mission_feed", - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - case "planning_artifact_missing": - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "intervention", - title: "Planner failed to return a plan", - detail: detailSummary ?? `${stepLabel} completed without a usable plan payload.`, - severity: "error", - audience: "mission_feed", - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - default: - if ( - event.eventType.startsWith("validation_") - || event.reason.startsWith("validation_") - ) { - return { - id: `timeline:${event.id}`, - at: event.createdAt, - kind: "validation", - title: "Validation update", - detail: detailSummary ?? `${stepLabel} validation changed.`, - severity: toRunViewSeverity(event.reason), - audience: "mission_feed", - source: "timeline", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - return null; - } - }; - - const buildRunViewRuntimeItem = ( - event: NonNullable[number], - stepById: Map, - ): MissionRunViewProgressItem | null => { - if ( - RUN_VIEW_NOISE_EVENT_TYPES.has(event.eventType) - || event.eventType === "heartbeat" - || event.eventType === "progress" - ) { - return null; - } - const step = event.stepId ? stepById.get(event.stepId) ?? null : null; - const payload = isRecord(event.payload) ? event.payload : null; - const rawReason = toOptionalString(payload?.reason); - const reasonLooksInternal = rawReason ? /^[a-z0-9_:-]+$/i.test(rawReason) : false; - const detail = toOptionalString(payload?.summary) - ?? toOptionalString(payload?.message) - ?? toOptionalString(payload?.directive) - ?? (reasonLooksInternal ? null : rawReason) - ?? null; - - if (event.eventType === "coordinator_steering" || event.eventType === "coordinator_broadcast") { - const delivered = payload?.delivered === true; - const title = (() => { - if (event.eventType === "coordinator_broadcast") return "Broadcast sent"; - return delivered ? "Steering delivered" : "Steering saved"; - })(); - return { - id: `runtime:${event.id}`, - at: event.occurredAt, - kind: "user", - title, - detail: detail ?? (delivered - ? "Mission steering was delivered to the active worker." - : "ADE saved the instruction for the coordinator, but no live worker session accepted it directly."), - severity: "info", - audience: "mission_feed", - source: "runtime", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - if (event.eventType === "intervention_opened") { - const title = toOptionalString(payload?.title) ?? "Intervention opened"; - const body = toOptionalString(payload?.body); - const requestedAction = toOptionalString(payload?.requestedAction); - return { - id: `runtime:${event.id}`, - at: event.occurredAt, - kind: "intervention", - title, - detail: body ?? detail ?? requestedAction ?? "ADE opened an intervention and paused the mission.", - severity: "warning", - audience: "mission_feed", - source: "runtime", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - if (event.eventType === "intervention_resolved") { - const resolutionLabel = - payload?.resolutionKind === "accept_defaults" - ? "Defaults accepted" - : payload?.resolutionKind === "skip_question" - ? "Question skipped" - : payload?.resolutionKind === "cancel_run" - ? "Run canceled" - : payload?.resolutionKind === "stale_phase_approval" - ? "Stale phase approval closed" - : "Operator decision applied"; - return { - id: `runtime:${event.id}`, - at: event.occurredAt, - kind: "user", - title: resolutionLabel, - detail: detail ?? (payload?.resolutionKind === "answer_provided" - ? "ADE resolved the intervention and resumed mission coordination." - : payload?.resolutionKind === "stale_phase_approval" - ? "ADE closed a stale phase approval without changing the run phase." - : "ADE applied the intervention outcome."), - severity: payload?.resolutionKind === "cancel_run" ? "warning" : "info", - audience: "mission_feed", - source: "runtime", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - if (event.eventType === "worker_message") { - const sourceLabel = toOptionalString(payload?.sourceLabel) ?? "One worker"; - const targetLabel = toOptionalString(payload?.targetLabel) ?? "another worker"; - return { - id: `runtime:${event.id}`, - at: event.occurredAt, - kind: "worker", - title: "Agent handoff", - detail: detail ?? `${sourceLabel} sent a message to ${targetLabel}.`, - severity: "info", - audience: "mission_feed", - source: "runtime", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - if ( - event.eventType === "validation_report" - || event.eventType === "validation_contract_unfulfilled" - || event.eventType === "validation_self_check_reminder" - || event.eventType === "validation_gate_blocked" - ) { - return { - id: `runtime:${event.id}`, - at: event.occurredAt, - kind: "validation", - title: "Validation signal", - detail: detail ?? `${step?.title ?? step?.stepKey ?? "Step"} validation state changed.`, - severity: toRunViewSeverity(event.eventType), - audience: "mission_feed", - source: "runtime", - stepId: event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - }; - } - return null; - }; - - const buildRunViewProgressLog = (args: { - mission: MissionDetail; - graph: OrchestratorRunGraph | null; - }): MissionRunViewProgressItem[] => { - const stepById = new Map((args.graph?.steps ?? []).map((step) => [step.id, step] as const)); - const items: MissionRunViewProgressItem[] = []; - const seenInterventionIds = new Set(); - const hasRuntimeInterventionEvents = new Set(); - for (const event of args.graph?.timeline ?? []) { - const item = buildRunViewTimelineItem(event, stepById); - if (item) items.push(item); - } - for (const event of args.graph?.runtimeEvents ?? []) { - const payload = isRecord(event.payload) ? event.payload : null; - const interventionId = toOptionalString(payload?.interventionId); - if (interventionId) { - seenInterventionIds.add(interventionId); - hasRuntimeInterventionEvents.add(event.eventType); - } - const item = buildRunViewRuntimeItem(event, stepById); - if (item) items.push(item); - } - for (const intervention of args.mission.interventions) { - if (seenInterventionIds.has(intervention.id)) continue; - items.push({ - id: `intervention:${intervention.id}:${intervention.status}`, - at: intervention.updatedAt || intervention.createdAt, - kind: "intervention", - title: intervention.title, - detail: intervention.requestedAction?.trim() || intervention.body, - severity: intervention.status === "open" ? "warning" : "info", - audience: "mission_feed", - source: "intervention", - }); - } - for (const event of args.mission.events) { - if (event.eventType !== "mission_intervention_resolved") { - continue; - } - if (hasRuntimeInterventionEvents.has("intervention_resolved")) continue; - items.push({ - id: `mission:${event.id}`, - at: event.createdAt, - kind: "user", - title: (() => { - const payload = isRecord(event.payload) ? event.payload : null; - if (payload?.resolutionKind === "accept_defaults") return "Defaults accepted"; - if (payload?.resolutionKind === "skip_question") return "Question skipped"; - if (payload?.resolutionKind === "cancel_run") return "Run canceled"; - if (payload?.resolutionKind === "stale_phase_approval") return "Stale phase approval closed"; - return "Intervention resolved"; - })(), - detail: event.summary, - severity: toRunViewSeverity(event.eventType), - audience: "mission_feed", - source: "mission", - }); - } - if (args.mission.status === "completed" && args.mission.completedAt) { - items.push({ - id: `mission-terminal:${args.mission.id}:completed`, - at: args.mission.completedAt, - kind: "system", - title: "Mission completed", - detail: args.mission.outcomeSummary?.trim() || "The mission finished successfully.", - severity: "success", - audience: "mission_feed", - source: "mission", - }); - } else if (args.mission.status === "failed" && args.mission.updatedAt) { - items.push({ - id: `mission-terminal:${args.mission.id}:failed`, - at: args.mission.updatedAt, - kind: "system", - title: "Mission failed", - detail: args.mission.lastError?.trim() || "The mission stopped with an error.", - severity: "error", - audience: "mission_feed", - source: "mission", - }); - } else if (args.mission.status === "canceled" && args.mission.updatedAt) { - items.push({ - id: `mission-terminal:${args.mission.id}:canceled`, - at: args.mission.updatedAt, - kind: "system", - title: "Mission canceled", - detail: args.mission.lastError?.trim() || "The mission was canceled before completion.", - severity: "warning", - audience: "mission_feed", - source: "mission", - }); - } - return items - .sort((left, right) => { - const delta = Date.parse(right.at) - Date.parse(left.at); - return Number.isFinite(delta) && delta !== 0 ? delta : right.id.localeCompare(left.id); - }) - .slice(0, 80); - }; - - const buildRunView = async ( - viewArgs: GetMissionRunViewArgs, - ): Promise => { - const missionId = String(viewArgs.missionId ?? "").trim(); - if (!missionId.length) return null; - let mission = missionService.get(missionId); - if (!mission) return null; - - const runs = orchestratorService.listRuns({ missionId, limit: 200 }); - const run = pickPreferredMissionRun(runs, viewArgs.runId); - const graph = run ? orchestratorService.getRunGraph({ runId: run.id, timelineLimit: 200 }) : null; - let stateDoc = run ? await getMissionStateDocument({ runId: run.id }) : null; - const workerStates = run ? getWorkerStates({ runId: run.id }) : []; - const latestIntervention = toRunViewLatestIntervention(mission); - const openIntervention = getScopedOpenIntervention({ mission, run }); - const openInterventionReasonCode = toOptionalString(asRecord(openIntervention?.metadata)?.reasonCode); - let coordinatorAvailability = stateDoc?.coordinatorAvailability - ?? ( - openInterventionReasonCode === "coordinator_unavailable" || openInterventionReasonCode === "coordinator_recovery_failed" - ? { - available: false, - mode: "continuation_required", - summary: openIntervention?.title ?? "Coordinator unavailable", - detail: openIntervention?.body ?? null, - updatedAt: openIntervention?.updatedAt ?? mission.updatedAt, - } satisfies MissionCoordinatorAvailability - : null - ) - ?? ( - run && coordinatorAgents.get(run.id)?.isAlive - ? { - available: true, - mode: "continuation_required", - summary: "Coordinator online", - detail: null, - updatedAt: mission.updatedAt, - } satisfies MissionCoordinatorAvailability - : null - ); - - const haltReason = buildRunViewHaltReason({ - mission, - runStatus: run?.status ?? null, - coordinatorAvailability, - latestIntervention, - openIntervention, - }); - let closeoutRequirements = graph - ? buildMissionCloseoutRequirements({ - mission, - graph, - policy: resolveMissionFinalizationPolicyForMission(mission.id), - finalization: stateDoc?.finalization ?? null, - stateDoc, - }) - : []; - let unmetCloseoutRequirements = closeoutRequirements.filter((requirement) => - requirement.required - && requirement.status !== "present" - && requirement.status !== "waived" - ); - const closeoutRequirementsChanged = stateDoc?.finalization - ? JSON.stringify(stateDoc.finalization.requirements ?? []) !== JSON.stringify(closeoutRequirements) - : false; - if ( - run?.status === "succeeded" - && graph - && stateDoc?.finalization - && stateDoc.finalization.status !== "finalization_failed" - && stateDoc.finalization.blocked !== true - && ( - mission.status !== "completed" - || stateDoc.finalization.contractSatisfied !== true - || closeoutRequirementsChanged - ) - ) { - await updateMissionCompletionFromStateDoc({ - runId: run.id, - graph, - mission, - finalization: stateDoc.finalization, - }); - mission = missionService.get(missionId) ?? mission; - stateDoc = await getMissionStateDocument({ runId: run.id }); - closeoutRequirements = buildMissionCloseoutRequirements({ - mission, - graph, - policy: resolveMissionFinalizationPolicyForMission(mission.id), - finalization: stateDoc?.finalization ?? null, - stateDoc, - }); - unmetCloseoutRequirements = closeoutRequirements.filter((requirement) => - requirement.required - && requirement.status !== "present" - && requirement.status !== "waived" - ); - } - const closeoutBlocked = - run?.status === "succeeded" - && mission.status !== "completed" - && unmetCloseoutRequirements.length > 0; - if (closeoutBlocked && run) { - const existingCoordinator = coordinatorAgents.get(run.id) ?? null; - if (existingCoordinator?.isAlive) { - coordinatorAvailability = { - available: true, - mode: "consult_only", - summary: "Mission execution is complete. The coordinator remains available for closeout evidence and follow-up questions.", - detail: "Post-completion messages stay in closeout/consult mode. New implementation work should create an explicit continuation.", - updatedAt: stateDoc?.finalization?.updatedAt ?? mission.updatedAt, - }; - } - } - const closeoutBlockDetail = closeoutBlocked - ? ( - stateDoc?.finalization?.summary?.trim() - || `Closeout is incomplete: ${unmetCloseoutRequirements.map((requirement) => requirement.label).join(", ")}.` - ) - : null; - const effectiveHaltReason = haltReason ?? (closeoutBlocked - ? { - source: "mission", - title: "Closeout incomplete", - detail: closeoutBlockDetail ?? "Closeout evidence is still missing.", - severity: "warning", - createdAt: stateDoc?.finalization?.updatedAt ?? run?.completedAt ?? mission.updatedAt, - } satisfies MissionRunViewHaltReason - : null); - - const activeStep = (() => { - const steps = filterExecutionSteps(graph?.steps ?? []); - const byPriority = [ - steps.find((step) => step.status === "running"), - steps.find((step) => step.status === "blocked"), - steps.find((step) => step.status === "ready" || step.status === "pending"), - steps.find((step) => step.status === "failed"), - ]; - return byPriority.find((step): step is OrchestratorStep => Boolean(step)) ?? null; - })(); - - const phaseRuntime = asRecord(isRecord(graph?.run.metadata) ? graph?.run.metadata.phaseRuntime : null); - const activeStepMeta = asRecord(activeStep?.metadata); - const displayStatus = (() => { - if (openIntervention) return "blocked" satisfies MissionRunViewDisplayStatus; - if (run?.status === "failed" || mission.status === "failed") return "failed" satisfies MissionRunViewDisplayStatus; - if (run?.status === "canceled" || mission.status === "canceled") return "canceled" satisfies MissionRunViewDisplayStatus; - if (closeoutBlocked) return "blocked" satisfies MissionRunViewDisplayStatus; - if (run?.status === "succeeded" || mission.status === "completed") return "completed" satisfies MissionRunViewDisplayStatus; - if (coordinatorAvailability?.available === false) return "blocked" satisfies MissionRunViewDisplayStatus; - if (run?.status === "queued" || run?.status === "bootstrapping" || mission.status === "planning") return "starting" satisfies MissionRunViewDisplayStatus; - if (run?.status === "paused") return "paused" satisfies MissionRunViewDisplayStatus; - if (run?.status === "active" || mission.status === "in_progress") return "running" satisfies MissionRunViewDisplayStatus; - return "not_started" satisfies MissionRunViewDisplayStatus; - })(); - - const progressLog = buildRunViewProgressLog({ mission, graph }); - const stepById = new Map((graph?.steps ?? []).map((step) => [step.id, step] as const)); - const attemptById = new Map((graph?.attempts ?? []).map((attempt) => [attempt.id, attempt] as const)); - const workers: MissionRunViewWorkerSummary[] = workerStates.map((worker) => { - const step = stepById.get(worker.stepId) ?? null; - const attempt = attemptById.get(worker.attemptId) ?? null; - return { - attemptId: worker.attemptId, - stepId: worker.stepId, - stepKey: step?.stepKey ?? null, - stepTitle: step?.title ?? null, - laneId: step?.laneId ?? null, - sessionId: worker.sessionId ?? null, - executorKind: worker.executorKind ?? null, - state: worker.state, - status: toRunViewWorkerStatus(worker, attempt?.status ?? null), - lastHeartbeatAt: worker.lastHeartbeatAt ?? null, - completedAt: worker.completedAt ?? null, - }; - }); - - for (const step of filterExecutionSteps(graph?.steps ?? []).filter((entry) => entry.status === "blocked")) { - if (workers.some((worker) => worker.stepId === step.id)) continue; - const latestAttempt = [...(graph?.attempts ?? [])] - .filter((attempt) => attempt.stepId === step.id) - .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0] ?? null; - workers.push({ - attemptId: latestAttempt?.id ?? null, - stepId: step.id, - stepKey: step.stepKey, - stepTitle: step.title, - laneId: step.laneId ?? null, - sessionId: latestAttempt?.executorSessionId ?? null, - executorKind: latestAttempt?.executorKind ?? null, - state: "blocked", - status: "blocked", - lastHeartbeatAt: latestAttempt?.completedAt ?? latestAttempt?.startedAt ?? latestAttempt?.createdAt ?? null, - completedAt: latestAttempt?.completedAt ?? null, - }); - } - - const missionComputerUse = computerUseArtifactBrokerService - ? buildComputerUseOwnerSnapshot({ - broker: computerUseArtifactBrokerService, - owner: { kind: "mission", id: missionId }, - limit: 50, - }) - : null; - - return { - missionId, - runId: run?.id ?? null, - lifecycle: { - missionStatus: mission.status, - runStatus: run?.status ?? null, - displayStatus, - summary: effectiveHaltReason?.detail - ?? ( - displayStatus === "running" - ? activeStep?.title - ? `Working on ${activeStep.title}.` - : "Mission is actively running." - : displayStatus === "starting" - ? "Preparing mission runtime and coordinator." - : displayStatus === "completed" - ? (mission.outcomeSummary?.trim() || "Mission completed.") - : displayStatus === "failed" - ? (mission.lastError?.trim() || "Mission failed.") - : displayStatus === "canceled" - ? "Mission was canceled." - : displayStatus === "paused" - ? "Mission is paused." - : displayStatus === "blocked" - ? "Mission is blocked and requires attention." - : "Mission has not started yet." - ), - startedAt: run?.startedAt ?? mission.startedAt ?? null, - completedAt: run?.completedAt ?? mission.completedAt ?? null, - }, - active: { - phaseKey: toOptionalString(activeStepMeta?.phaseKey) - ?? toOptionalString(phaseRuntime?.currentPhaseKey) - ?? toOptionalString(stateDoc?.progress.currentPhase) - ?? null, - phaseName: toOptionalString(activeStepMeta?.phaseName) - ?? toOptionalString(phaseRuntime?.currentPhaseName) - ?? toOptionalString(stateDoc?.progress.currentPhase) - ?? null, - stepId: activeStep?.id ?? null, - stepKey: activeStep?.stepKey ?? null, - stepTitle: activeStep?.title ?? null, - featureLabel: toOptionalString(activeStepMeta?.featureLabel) - ?? toOptionalString(activeStepMeta?.featureKey) - ?? toOptionalString(activeStepMeta?.phaseName) - ?? null, - }, - coordinator: { - available: coordinatorAvailability?.available ?? null, - mode: coordinatorAvailability?.mode ?? null, - summary: coordinatorAvailability?.summary ?? null, - detail: coordinatorAvailability?.detail ?? null, - updatedAt: coordinatorAvailability?.updatedAt ?? null, - }, - latestIntervention, - haltReason: effectiveHaltReason, - workers: workers.sort((left, right) => { - const leftAt = Date.parse(left.completedAt || left.lastHeartbeatAt || ""); - const rightAt = Date.parse(right.completedAt || right.lastHeartbeatAt || ""); - return (Number.isFinite(rightAt) ? rightAt : 0) - (Number.isFinite(leftAt) ? leftAt : 0); - }), - progressLog, - lastMeaningfulProgress: progressLog[0] ?? null, - closeoutRequirements, - computerUse: missionComputerUse, - }; - }; - - const getRunView = async ( - viewArgs: GetMissionRunViewArgs, - ): Promise => { - const missionId = String(viewArgs.missionId ?? "").trim(); - if (!missionId.length) return null; - const runId = typeof viewArgs.runId === "string" ? viewArgs.runId.trim() : ""; - const cacheKey = `${missionId}::${runId}`; - const now = Date.now(); - const cached = runViewCache.get(cacheKey); - - if (cached) { - const ageMs = now - cached.updatedAt; - if (cached.value !== null && ageMs < RUN_VIEW_CACHE_TTL_MS) { - return cached.value; - } - if (cached.inFlight) { - if (cached.value !== null && ageMs < RUN_VIEW_STALE_WHILE_REFRESHING_MS) { - return cached.value; - } - return cached.inFlight; - } - } - - const inFlight = buildRunView(viewArgs) - .then((view) => { - runViewCache.set(cacheKey, { - value: view, - updatedAt: Date.now(), - inFlight: null, - }); - return view; - }) - .catch((error) => { - if (cached) { - runViewCache.set(cacheKey, { ...cached, inFlight: null }); - } else { - runViewCache.delete(cacheKey); - } - throw error; - }); - - runViewCache.set(cacheKey, { - value: cached?.value ?? null, - updatedAt: cached?.updatedAt ?? 0, - inFlight, - }); - return inFlight; - }; - - // ── Execution Plan Preview ────────────────────────────────── - const getExecutionPlanPreview = (previewArgs: { runId: string }): ExecutionPlanPreview | null => { - const runId = previewArgs.runId; - try { - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const mission = missionService.get(graph.run.missionId); - if (!mission) return null; - - const teamManifest = runTeamManifests.get(runId); - // Prefer mission phase settings, then use default runtime policy values. - const { settings: previewPhaseSettings } = resolveActivePhaseSettings(graph.run.missionId); - const recoveryPolicy: RecoveryLoopPolicy = previewPhaseSettings.recoveryLoop ?? DEFAULT_RECOVERY_LOOP_POLICY; - const integrationPrPlan: IntegrationPrPolicy = previewPhaseSettings.integrationPr ?? DEFAULT_INTEGRATION_PR_POLICY; - - // Group steps into phases by stepType - const phaseMap = new Map(); - for (const step of graph.steps) { - const stepMeta = isRecord(step.metadata) ? step.metadata : {}; - const phase = typeof stepMeta.stepType === "string" && stepMeta.stepType.trim().length > 0 - ? stepMeta.stepType.trim() - : step.stepKey.replace(/_\d+$/, ""); - const bucket = phaseMap.get(phase) ?? []; - bucket.push(step); - phaseMap.set(phase, bucket); - } - - const phases: ExecutionPlanPhase[] = []; - for (const [phaseName, phaseSteps] of phaseMap.entries()) { - const stepPreviews: ExecutionPlanStepPreview[] = phaseSteps.map((step) => { - const stepMeta = isRecord(step.metadata) ? step.metadata : {}; - const workerAssignment = teamManifest?.workers.find((w) => w.assignedStepKeys.includes(step.stepKey)); - const role: OrchestratorWorkerRole = workerAssignment?.role ?? inferRoleFromStepMetadata(stepMeta, step.stepKey); - const stepExecutorKind = typeof stepMeta.executorKind === "string" - ? (stepMeta.executorKind as OrchestratorExecutorKind) - : workerAssignment?.executorKind ?? "opencode"; - return { - stepKey: step.stepKey, - title: step.title ?? step.stepKey, - role, - executorKind: stepExecutorKind, - model: typeof stepMeta.model === "string" ? stepMeta.model : "default", - laneId: step.laneId ?? null, - dependencies: step.dependencyStepIds ?? [], - gateType: typeof stepMeta.gateType === "string" ? stepMeta.gateType : null, - recoveryOnFailure: recoveryPolicy.enabled - }; - }); - - const firstStep = phaseSteps[0]; - const firstStepMeta = firstStep ? (isRecord(firstStep.metadata) ? firstStep.metadata : {}) : {}; - const firstStepExecutorKind = typeof firstStepMeta.executorKind === "string" - ? (firstStepMeta.executorKind as OrchestratorExecutorKind) - : "opencode"; - phases.push({ - phase: phaseName, - enabled: true, - stepCount: phaseSteps.length, - steps: stepPreviews, - model: typeof firstStepMeta.model === "string" ? firstStepMeta.model : "default", - executorKind: firstStepExecutorKind, - gatePolicy: typeof firstStepMeta.gateType === "string" ? firstStepMeta.gateType : "none", - recoveryEnabled: recoveryPolicy.enabled - }); - } - - // Team summary - const workerCount = teamManifest?.workers.length ?? graph.steps.length; - const parallelLanes = teamManifest?.parallelLanes.length ?? new Set(graph.steps.map((s) => s.laneId).filter(Boolean)).size; - const roles = teamManifest - ? [...new Set(teamManifest.workers.map((w) => w.role))] - : [...new Set(phases.flatMap((p) => p.steps.map((s) => s.role)))]; - - // Alignment check - const driftNotes: string[] = []; - const actualStepKeys = new Set(graph.steps.map((s) => s.stepKey)); - if (teamManifest) { - for (const w of teamManifest.workers) { - for (const key of w.assignedStepKeys) { - if (!actualStepKeys.has(key)) { - driftNotes.push(`Team manifest references step "${key}" which no longer exists in run.`); - } - } - } - } - const aligned = driftNotes.length === 0; - - // Strategy - const strategy = teamManifest - ? `${teamManifest.complexity.estimatedScope} ${teamManifest.complexity.domain} mission with ${workerCount} workers` - : `${graph.steps.length}-step execution plan`; - - return { - runId, - missionId: graph.run.missionId, - generatedAt: nowIso(), - strategy, - phases, - teamSummary: { - workerCount, - parallelLanes, - roles - }, - recoveryPolicy, - integrationPrPlan, - aligned, - driftNotes - }; - } catch (error) { - logger.debug("ai_orchestrator.execution_plan_preview_failed", { - runId, - error: error instanceof Error ? error.message : String(error) - }); - return null; - } - }; - - const getMissionStateDocument = async ( - stateArgs: GetMissionStateDocumentArgs - ): Promise => { - const runId = String(stateArgs.runId ?? "").trim(); - if (!runId.length || !projectRoot) return null; - try { - return await readMissionStateDocument({ - projectRoot, - runId, - }); - } catch (error) { - logger.debug("ai_orchestrator.mission_state_read_failed", { - runId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }; - - // ── Aggregated Usage Stats (delegated to metricsAndUsage) ── - const getAggregatedUsage = (usageArgs: GetAggregatedUsageArgs): AggregatedUsageStats => - getAggregatedUsageCtx(ctx, usageArgs); - - const propagateAttemptTokenUsage = (runId: string, attemptId: string): void => - propagateAttemptTokenUsageCtx(ctx, runId, attemptId); - - if (hasRecoverableRuntimeWork()) { - hydratePersistedAttemptRuntimeState(); - hydrateRuntimeSignalsFromEventBus(); - replayOpenQuestionsFromEventBus(); - void (async () => { - try { - await reconcileThreadedMessagingState(); - } catch (error) { - logger.debug("ai_orchestrator.chat_reconciliation_failed", { - error: error instanceof Error ? error.message : String(error) - }); - } finally { - if (!disposed) { - startHealthSweepLoop(); - } - } - })(); - } else { - startHealthSweepLoop(); - } - - // --------------------------------------------------------------------------- - // Smart Agent Recovery: AI-diagnosed failure handling with tiered response - // --------------------------------------------------------------------------- - - const ALL_MISSION_LOG_CHANNELS: MissionLogChannel[] = [ - "timeline", - "runtime", - "chat", - "outputs", - "reflections", - "retrospectives", - "interventions", - ]; - - const normalizeMissionLogChannels = (channels?: MissionLogChannel[]): MissionLogChannel[] => { - if (!Array.isArray(channels) || channels.length === 0) return ALL_MISSION_LOG_CHANNELS; - const out: MissionLogChannel[] = []; - for (const raw of channels) { - if ( - raw === "timeline" || - raw === "runtime" || - raw === "chat" || - raw === "outputs" || - raw === "reflections" || - raw === "retrospectives" || - raw === "interventions" - ) { - if (!out.includes(raw)) out.push(raw); - } - } - return out.length > 0 ? out : ALL_MISSION_LOG_CHANNELS; - }; - - const toMissionLogLevel = (args: { - eventType?: string | null; - message?: string | null; - }): MissionLogEntry["level"] => { - const text = `${args.eventType ?? ""} ${args.message ?? ""}`.toLowerCase(); - if ( - text.includes("failed") || - text.includes("error") || - text.includes("unavailable") || - text.includes("hard_cap") || - text.includes("budget_exceeded") - ) { - return "error"; - } - if ( - text.includes("warning") || - text.includes("blocked") || - text.includes("paused") || - text.includes("retry") - ) { - return "warning"; - } - return "info"; - }; - - const pickMissionLogRunId = (missionId: string, requestedRunId?: string | null): string | null => { - const runs = orchestratorService.listRuns({ missionId, limit: 200 }); - return pickPreferredMissionRun(runs, requestedRunId)?.id ?? null; - }; - - const getMissionLogs = async (args: GetMissionLogsArgs): Promise => { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId.length) throw new Error("missionId is required."); - const mission = missionService.get(missionId); - if (!mission) throw new Error(`Mission not found: ${missionId}`); - - const channels = normalizeMissionLogChannels(args.channels); - const channelSet = new Set(channels); - const runId = pickMissionLogRunId(missionId, args.runId); - const entries: MissionLogEntry[] = []; - const pushEntry = (entry: MissionLogEntry) => entries.push(entry); - - if (channelSet.has("timeline")) { - for (const event of mission.events) { - pushEntry({ - id: `mission-event:${event.id}`, - missionId, - runId: runId ?? null, - channel: "timeline", - level: toMissionLogLevel({ eventType: event.eventType, message: event.summary }), - at: event.createdAt, - title: event.eventType, - message: event.summary, - payload: event.payload, - }); - } - } - - if (runId && channelSet.has("timeline")) { - const timeline = orchestratorService.listTimeline({ runId, limit: 4_000 }); - for (const event of timeline) { - pushEntry({ - id: `timeline:${event.id}`, - missionId, - runId, - channel: "timeline", - level: toMissionLogLevel({ eventType: event.eventType, message: event.reason }), - at: event.createdAt, - title: event.eventType, - message: event.reason, - stepId: event.stepId ?? null, - attemptId: event.attemptId ?? null, - payload: event.detail ?? null, - }); - } - } - - if (runId && channelSet.has("runtime")) { - const runtimeEvents = orchestratorService.listRuntimeEvents({ runId, limit: 4_000 }); - for (const event of runtimeEvents) { - const payload = isRecord(event.payload) ? event.payload : null; - const summary = - (payload && typeof payload.summary === "string" && payload.summary.trim().length > 0) - ? payload.summary.trim() - : event.eventType; - pushEntry({ - id: `runtime:${event.id}`, - missionId, - runId, - channel: "runtime", - level: toMissionLogLevel({ eventType: event.eventType, message: summary }), - at: event.occurredAt, - title: event.eventType, - message: summary, - stepId: event.stepId ?? null, - attemptId: event.attemptId ?? null, - payload, - }); - } - } - - if (channelSet.has("chat")) { - const seenMessageIds = new Set(); - const addChatEntry = (message: OrchestratorChatMessage): void => { - if (seenMessageIds.has(message.id)) return; - seenMessageIds.add(message.id); - const role = message.role; - const prefix = role === "user" ? "User" : role === "agent" ? "Agent" : role === "worker" ? "Worker" : "Orchestrator"; - const text = String(message.content ?? "").trim(); - pushEntry({ - id: `chat:${message.id}`, - missionId, - runId: message.runId ?? runId ?? null, - channel: "chat", - level: role === "orchestrator" ? "warning" : "info", - at: message.timestamp, - title: `${prefix} message`, - message: text.length > 0 ? text : "(empty message)", - stepKey: message.stepKey ?? null, - threadId: message.threadId ?? null, - payload: isRecord(message.metadata) ? message.metadata : null, - }); - }; - - const globalMessages = getGlobalChat({ missionId, limit: 2_000 }); - for (const message of globalMessages) addChatEntry(message); - - const threads = listChatThreads({ missionId, includeClosed: true }); - for (const thread of threads) { - const threadMessages = getThreadMessages({ missionId, threadId: thread.id, limit: 500 }); - for (const message of threadMessages) addChatEntry(message); - } - } - - if (runId && channelSet.has("outputs")) { - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const stepById = new Map(graph.steps.map((step) => [step.id, step] as const)); - for (const attempt of graph.attempts) { - const summary = attempt.resultEnvelope?.summary?.trim(); - const errorMessage = attempt.errorMessage?.trim(); - if (!summary && !errorMessage) continue; - const step = stepById.get(attempt.stepId); - const msg = summary ?? errorMessage ?? `Attempt ${attempt.status}`; - pushEntry({ - id: `output:${attempt.id}`, - missionId, - runId, - channel: "outputs", - level: attempt.status === "failed" ? "error" : attempt.status === "blocked" ? "warning" : "info", - at: attempt.completedAt ?? attempt.startedAt ?? attempt.createdAt, - title: step ? `Step output: ${step.stepKey}` : "Step output", - message: msg, - stepId: attempt.stepId, - stepKey: step?.stepKey ?? null, - attemptId: attempt.id, - payload: attempt.resultEnvelope?.outputs ?? null, - }); - } - const checkpoint = getContextCheckpoint({ missionId }); - if (checkpoint) { - pushEntry({ - id: `output:checkpoint:${checkpoint.id}`, - missionId, - runId, - channel: "outputs", - level: "info", - at: checkpoint.createdAt, - title: "Context checkpoint", - message: `Checkpoint saved for ${checkpoint.trigger ?? "runtime state"}.`, - payload: { - trigger: checkpoint.trigger, - summary: checkpoint.summary, - }, - }); - } - } - - if (channelSet.has("reflections")) { - const reflections = orchestratorService.listReflections({ - missionId, - ...(runId ? { runId } : {}), - limit: 500, - }); - for (const reflection of reflections) { - const message = `${reflection.observation} Recommendation: ${reflection.recommendation}`; - pushEntry({ - id: `reflection:${reflection.id}`, - missionId, - runId: reflection.runId, - channel: "reflections", - level: "info", - at: reflection.occurredAt, - title: `Reflection (${reflection.signalType})`, - message, - stepId: reflection.stepId, - attemptId: reflection.attemptId, - payload: { - phase: reflection.phase, - agentRole: reflection.agentRole, - context: reflection.context, - }, - }); - } - } - - if (channelSet.has("retrospectives")) { - const retrospectives = orchestratorService.listRetrospectives({ missionId, limit: 100 }); - for (const retrospective of retrospectives) { - if (runId && retrospective.runId !== runId) continue; - const summaryParts: string[] = []; - if (retrospective.wins.length > 0) summaryParts.push(`Wins: ${retrospective.wins.slice(0, 3).join("; ")}`); - if (retrospective.failures.length > 0) summaryParts.push(`Failures: ${retrospective.failures.slice(0, 3).join("; ")}`); - if (retrospective.followUpActions.length > 0) { - summaryParts.push(`Follow-ups: ${retrospective.followUpActions.slice(0, 3).join("; ")}`); - } - pushEntry({ - id: `retrospective:${retrospective.id}`, - missionId, - runId: retrospective.runId, - channel: "retrospectives", - level: retrospective.finalStatus === "failed" ? "error" : retrospective.finalStatus === "canceled" ? "warning" : "info", - at: retrospective.generatedAt, - title: "Run retrospective", - message: summaryParts.length > 0 ? summaryParts.join(" | ") : "Retrospective generated.", - payload: { - finalStatus: retrospective.finalStatus, - wins: retrospective.wins, - failures: retrospective.failures, - unresolvedRisks: retrospective.unresolvedRisks, - followUpActions: retrospective.followUpActions, - topPainPoints: retrospective.topPainPoints, - topImprovements: retrospective.topImprovements, - estimatedImpact: retrospective.estimatedImpact, - }, - }); - } - } - - if (channelSet.has("interventions")) { - for (const intervention of mission.interventions) { - pushEntry({ - id: `intervention:${intervention.id}`, - missionId, - runId: runId ?? null, - channel: "interventions", - level: intervention.status === "open" ? "warning" : "info", - at: intervention.updatedAt ?? intervention.createdAt, - title: intervention.title, - message: intervention.body, - interventionId: intervention.id, - payload: { - status: intervention.status, - interventionType: intervention.interventionType, - requestedAction: intervention.requestedAction, - resolutionNote: intervention.resolutionNote, - }, - }); - } - } - - const sorted = [...entries].sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); - const total = sorted.length; - const offsetRaw = Number(args.cursor ?? "0"); - const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0; - const limitRaw = Number(args.limit ?? 200); - const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1_000, Math.floor(limitRaw))) : 200; - const paged = sorted.slice(offset, offset + limit); - const nextCursor = offset + limit < total ? String(offset + limit) : null; - return { - entries: paged, - nextCursor, - total, - }; - }; - - const exportMissionLogs = async (args: ExportMissionLogsArgs): Promise => { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId.length) throw new Error("missionId is required."); - const runId = pickMissionLogRunId(missionId, args.runId); - const includeArtifacts = args.includeArtifacts === true; - const channels = ALL_MISSION_LOG_CHANNELS; - - const allEntries: MissionLogEntry[] = []; - let cursor: string | null = null; - for (;;) { - const page = await getMissionLogs({ - missionId, - runId, - channels, - cursor, - limit: 1_000, - }); - allEntries.push(...page.entries); - if (!page.nextCursor) break; - cursor = page.nextCursor; - } - - const root = projectRoot ?? process.cwd(); - const runPart = runId ?? "latest"; - const bundlePath = nodePath.join(resolveAdeLayout(root).logBundlesDir, missionId, runPart); - fs.mkdirSync(bundlePath, { recursive: true }); - - const files: ExportMissionLogsResult["manifest"]["files"] = []; - const writeBundleFile = (name: string, content: string, entriesCount = 0): void => { - const filePath = nodePath.join(bundlePath, name); - fs.writeFileSync(filePath, content, "utf8"); - files.push({ - name, - path: filePath, - bytes: Buffer.byteLength(content, "utf8"), - entries: entriesCount, - }); - }; - const copyBundleFile = (name: string, sourcePath: string): void => { - if (!sourcePath.trim().length || !fs.existsSync(sourcePath)) return; - const filePath = nodePath.join(bundlePath, name); - fs.mkdirSync(nodePath.dirname(filePath), { recursive: true }); - fs.copyFileSync(sourcePath, filePath); - files.push({ - name, - path: filePath, - bytes: fs.statSync(filePath).size, - entries: 0, - }); - }; - - const grouped = new Map(); - for (const channel of channels) grouped.set(channel, []); - for (const entry of allEntries) { - const bucket = grouped.get(entry.channel); - if (bucket) bucket.push(entry); - } - - for (const channel of channels) { - const channelEntries = grouped.get(channel) ?? []; - writeBundleFile(`${channel}.json`, `${JSON.stringify(channelEntries, null, 2)}\n`, channelEntries.length); - } - - if (includeArtifacts) { - const mission = missionService.get(missionId); - if (mission) { - writeBundleFile( - "mission-detail.json", - `${JSON.stringify( - { - id: mission.id, - title: mission.title, - status: mission.status, - prompt: mission.prompt, - createdAt: mission.createdAt, - updatedAt: mission.updatedAt, - artifacts: mission.artifacts, - interventions: mission.interventions, - }, - null, - 2, - )}\n`, - ); - } - writeBundleFile( - "chat-transcript.json", - `${JSON.stringify(getChat({ missionId }), null, 2)}\n`, - ); - if (runId) { - const runGraph = orchestratorService.getRunGraph({ runId, timelineLimit: 5_000 }); - const logsDir = resolveAdeLayout(root).logsDir; - writeBundleFile("run-graph.json", `${JSON.stringify(runGraph, null, 2)}\n`); - writeBundleFile( - "worker-checkpoints.json", - `${JSON.stringify(orchestratorService.getWorkerCheckpointsForRun({ runId }), null, 2)}\n`, - ); - copyBundleFile("mission-state.json", getMissionStateDocumentPath(root, runId)); - copyBundleFile("coordinator-checkpoint.json", getCoordinatorCheckpointPath(root, runId)); - copyBundleFile("logs/main.jsonl", nodePath.join(logsDir, "main.jsonl")); - copyBundleFile("logs/runtime.log", nodePath.join(logsDir, "runtime.log")); - copyBundleFile("logs/coordinator.claude.log", nodePath.join(logsDir, `coordinator-${runId}.claude.log`)); - for (const attempt of runGraph.attempts) { - const metadata = isRecord(attempt.metadata) ? attempt.metadata : null; - const transcriptPath = typeof metadata?.transcriptPath === "string" ? metadata.transcriptPath.trim() : ""; - if (!transcriptPath.length) continue; - const fileName = nodePath.basename(transcriptPath); - copyBundleFile(nodePath.join("transcripts", fileName), transcriptPath); - } - } - } - - const exportedAt = nowIso(); - const manifest: ExportMissionLogsResult["manifest"] = { - schema: "ade.mission-log-bundle.v1", - missionId, - runId, - exportedAt, - channels, - entryCount: allEntries.length, - files, - includeArtifacts, - }; - writeBundleFile("manifest.json", `${JSON.stringify(manifest, null, 2)}\n`); - - return { - bundlePath, - manifest, - }; - }; - - return { - startMissionRun, - resumeRun, - cancelRunGracefully, - cleanupTeamResources, - - onOrchestratorRuntimeEvent(event: OrchestratorRuntimeEvent) { - if (disposed) return; - if (!event.runId) return; - const runId = event.runId; - updateWorkerStateFromEvent(event); - - const isStepCompletionEvent = event.stepId && (event.reason === "attempt_completed" || event.reason === "skipped"); - const isAttemptCompletionShadowEvent = - event.type === "orchestrator-attempt-updated" && - event.reason === "completed" && - Boolean(event.stepId) && - Boolean(event.attemptId); - const isAttemptCompletionEvent = (event.reason === "attempt_completed" || event.reason === "completed") && Boolean(event.attemptId); - - // ── Shared graph fetch for coordinator routing + follow-up checks ── - let cachedEventGraph: OrchestratorRunGraph | null = null; - const getEventGraph = (): OrchestratorRunGraph => { - if (!cachedEventGraph) { - cachedEventGraph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - } - return cachedEventGraph; - }; - - if ( - event.reason === "validation_contract_unfulfilled" || - event.reason === "validation_self_check_reminder" || - event.reason === "validation_gate_blocked" - ) { - try { - emitValidationSystemSignal({ - event, - graph: getEventGraph(), - }); - } catch (signalError) { - logger.debug("ai_orchestrator.validation_system_signal_emit_failed", { - runId, - reason: event.reason, - stepId: event.stepId ?? null, - attemptId: event.attemptId ?? null, - error: signalError instanceof Error ? signalError.message : String(signalError) - }); - } - } - - // VAL-BUDGET-001: When completeAttempt detects token-budget-exceeded and - // pauses the run, create a budget_limit_reached intervention matching the - // hard cap path in coordinatorTools (pauseOnBudgetHardCap). - if (event.type === "orchestrator-run-updated" && event.reason === "budget_exceeded") { - try { - const missionId = getMissionIdForRun(runId); - if (missionId) { - const graph = getEventGraph(); - const detail = graph.run.lastError ?? "Total token budget exceeded."; - pauseMissionWithIntervention({ - missionId, - interventionType: "budget_limit_reached", - title: "Token budget exceeded", - body: detail, - requestedAction: "Raise budget limits, wait for the 5-hour window to reset, or cancel the mission.", - metadata: { source: "completeAttempt_budget_exceeded", runId }, - }); - } - } catch (budgetError) { - logger.debug("ai_orchestrator.budget_exceeded_intervention_failed", { - runId, - error: budgetError instanceof Error ? budgetError.message : String(budgetError), - }); - } - } - - if (event.type === "orchestrator-attempt-updated" && event.attemptId && event.stepId) { - try { - const graph = getEventGraph(); - const step = graph.steps.find((entry) => entry.id === event.stepId) ?? null; - const attempt = graph.attempts.find((entry) => entry.id === event.attemptId) ?? null; - const missionId = graph.run.missionId ?? getMissionIdForRun(runId); - if (missionId) { - const threadStatus = - attempt - && ( - attempt.status === "succeeded" - || attempt.status === "failed" - || attempt.status === "blocked" - || attempt.status === "canceled" - ) - ? "closed" - : "active"; - const fallbackStepLabel = step?.stepKey?.trim() || step?.title?.trim() || "starting"; - upsertThread({ - missionId, - threadId: `worker:${missionId}:${event.attemptId}`, - threadType: "worker", - title: `Worker: ${fallbackStepLabel}`, - target: { - kind: "worker", - runId, - stepId: step?.id ?? event.stepId, - stepKey: step?.stepKey ?? null, - attemptId: event.attemptId, - sessionId: attempt?.executorSessionId ?? null, - laneId: step?.laneId ?? null, - }, - status: threadStatus, - }); - } - } catch (threadError) { - logger.debug("ai_orchestrator.worker_thread_upsert_failed", { - runId, - stepId: event.stepId, - attemptId: event.attemptId, - reason: event.reason, - error: threadError instanceof Error ? threadError.message : String(threadError), - }); - } - } - - // ── Strict coordinator ownership gate ── - const existingCoordinator = coordinatorAgents.get(runId) ?? null; - let coordinator = existingCoordinator?.isAlive ? existingCoordinator : null; - if (!coordinator && existingCoordinator && !existingCoordinator.isAlive) { - const recovered = attemptCoordinatorRecovery(runId); - if (recovered?.isAlive) coordinator = recovered; - } - if (!coordinator && hasActivePersistedCoordinatorChatSessionForRun(runId)) { - const ensured = ensureCoordinatorAgentV2(runId); - if (ensured.agent?.isAlive) coordinator = ensured.agent; - } - const coordinatorWritesSuppressed = coordinatorWriteBarrierRuns.has(runId); - const coordinatorOwnershipRequired = runRequiresCoordinatorOwnership(getEventGraph().run); - if (!coordinatorOwnershipRequired) { - if (coordinator) { - resolveCoordinatorHealthInterventions({ - runId, - note: "Coordinator runtime is healthy again; closed stale coordinator availability intervention.", - resolutionReason: "coordinator_recovered", - }); - } - void syncMissionFromRun(runId, event.reason); - logger.debug("ai_orchestrator.coordinator_gate_skipped_manual_run", { - runId, - eventType: event.type, - reason: event.reason, - }); - } else if (!coordinator) { - const runStatus = getEventGraph().run.status; - const terminalCoordinatorRun = - typeof runStatus === "string" && TERMINAL_COORDINATOR_RUN_STATUSES.has(runStatus as OrchestratorRunStatus); - const waitingForCoordinatorStartup = - !existingCoordinator && - event.type === "orchestrator-run-updated" && - (runStatus === "bootstrapping" || runStatus === "queued"); - if (waitingForCoordinatorStartup) { - return; - } - if (coordinatorWritesSuppressed || terminalCoordinatorRun) { - if (event.reason === "finalized" || terminalCoordinatorRun) { - resolveCoordinatorHealthInterventions({ - runId, - note: `Run reached terminal state (${runStatus}) and stale coordinator availability intervention was closed.`, - resolutionReason: "run_terminal", - }); - } - void syncMissionFromRun(runId, event.reason); - logger.info("ai_orchestrator.coordinator_unavailable_suppressed_terminal", { - runId, - eventType: event.type, - reason: event.reason, - runStatus, - writeBarrier: coordinatorWritesSuppressed, - }); - return; - } - if (event.reason !== "finalized") { - const missionId = getMissionIdForRun(runId); - if (missionId) { - let suppressGenericIntervention = false; - const launchFailure = getRunLaunchFailureMetadata(runId); - if (launchFailure && hasOpenMissionLaunchFailureIntervention({ missionId, runId })) { - suppressGenericIntervention = true; - logger.info("ai_orchestrator.coordinator_unavailable_suppressed", { - runId, - missionId, - eventType: event.type, - reason: event.reason, - failureStage: launchFailure.failureStage ?? null, - }); - } - - // Skip creating a generic coordinator_unavailable intervention if - // onCoordinatorRuntimeFailure already handled this failure and - // created a more specific intervention with full failure context. - const mission = missionService.get(missionId); - const alreadyHandledByRuntimeFailure = mission?.interventions.some((entry) => { - if (entry.status !== "open") return false; - const meta = isRecord(entry.metadata) ? entry.metadata : null; - return meta?.coordinatorFailureHandled === true && meta?.runId === runId; - }) ?? false; - if (alreadyHandledByRuntimeFailure) { - suppressGenericIntervention = true; - logger.info("ai_orchestrator.coordinator_unavailable_suppressed_runtime_failure", { - runId, - missionId, - eventType: event.type, - reason: event.reason, - }); - } - - if (!suppressGenericIntervention) { - pauseRunWithIntervention({ - runId, - missionId, - stepId: event.stepId ?? null, - source: "transition_decision", - reasonCode: existingCoordinator ? "coordinator_recovery_failed" : "coordinator_unavailable", - title: existingCoordinator ? "Coordinator recovery failed" : "Coordinator unavailable", - body: existingCoordinator - ? "Coordinator agent terminated and could not be recovered. Mission paused to prevent non-autonomous fallback logic." - : "Coordinator agent is not available for this run. Mission paused to prevent non-autonomous fallback logic.", - requestedAction: "Resume after coordinator runtime is healthy, or restart the mission run.", - metadata: { - runtimeEventType: event.type, - runtimeEventReason: event.reason, - attemptId: event.attemptId ?? null - } - }); - } - } - } else if (runStatus === "succeeded" || runStatus === "failed" || runStatus === "canceled") { - resolveCoordinatorHealthInterventions({ - runId, - note: `Run reached terminal state (${runStatus}) and stale coordinator availability intervention was closed.`, - resolutionReason: "run_terminal", - }); - } - logger.warn("ai_orchestrator.coordinator_unavailable", { - runId, - eventType: event.type, - reason: event.reason, - recovered: false - }); - } else { - resolveCoordinatorHealthInterventions({ - runId, - note: "Coordinator runtime is healthy again; closed stale coordinator availability intervention.", - resolutionReason: "coordinator_recovered", - }); - } - - // Failed/canceled terminal runs end the coordinator. Successful runs keep - // it available for closeout evidence and follow-up consults. - if (event.reason === "finalized") { - const terminalRunStatus = getEventGraph().run.status; - if (terminalRunStatus === "succeeded" || terminalRunStatus === "failed" || terminalRunStatus === "canceled") { - resolveCoordinatorHealthInterventions({ - runId, - note: `Run reached terminal state (${terminalRunStatus}) and stale coordinator availability intervention was closed.`, - resolutionReason: "run_terminal", - }); - } - void syncMissionFromRun(runId, event.reason); - if (terminalRunStatus === "succeeded") { - setCoordinatorAvailability(runId, { - available: true, - mode: "consult_only", - summary: "Mission execution is complete. The coordinator remains available for closeout evidence and follow-up questions.", - detail: "Post-completion messages stay in closeout/consult mode. New implementation work should create an explicit continuation.", - updatedAt: nowIso(), - }, { graph: getEventGraph() }); - return; - } - endCoordinatorAgentV2(runId); - return; - } - - // ── Hooks: always fire (observability, not decisions) ── - if (isStepCompletionEvent && event.stepId) { - dispatchOrchestratorHook({ - event: "TaskCompleted", - runId, - stepId: event.stepId, - attemptId: event.attemptId ?? null, - sessionId: null, - reason: event.reason, - triggerSource: "runtime_event", - eventAt: event.at, - metadata: { - runtimeEventType: event.type - } - }); - } - - // ── Token consumption: always propagate (bookkeeping, not decisions) ── - if (isAttemptCompletionEvent && event.attemptId) { - propagateAttemptTokenUsage(event.runId, event.attemptId); - } - - if (isAttemptCompletionEvent) { - try { - maybeForwardSubagentCompletionRollup({ - event, - graph: getEventGraph(), - }); - } catch (error) { - logger.debug("ai_orchestrator.subagent_completion_rollup_unhandled", { - runId: event.runId, - attemptId: event.attemptId ?? null, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // ── Safety check: always run (guardrail, not decision) ── - if (isAttemptCompletionEvent && event.stepId && event.runId) { - try { - const safetyGraph = getEventGraph(); - const step = safetyGraph.steps.find((s) => s.id === event.stepId); - const meta = step?.metadata as Record | null; - const stepType = meta?.stepType as string | undefined; - if (stepType === "conflict-resolution" || stepType === "pr-review") { - const attempt = safetyGraph.attempts - .filter((a) => a.stepId === event.stepId) - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; - const summary = attempt?.resultEnvelope?.summary ?? ""; - if (/git\s+push/i.test(summary)) { - logger.warn("ai_orchestrator.safety_git_push_detected", { - runId: event.runId, - stepId: event.stepId, - stepType, - stepKey: step?.stepKey, - message: "A conflict-resolution or pr-review worker may have attempted git push" - }); - } - } - } catch { - // Non-critical safety check — ignore errors - } - } - - if (isStepCompletionEvent && event.stepId) { - try { - const stepOutcomeGraph = getEventGraph(); - const stepOutcome = buildMissionStateStepOutcomeFromGraph(stepOutcomeGraph, event.stepId); - if (stepOutcome) { - updateMissionStateDoc(runId, { - addStepOutcome: stepOutcome, - }, { graph: stepOutcomeGraph }); - } - } catch (error) { - logger.debug("ai_orchestrator.mission_state_step_outcome_update_failed", { - runId, - stepId: event.stepId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // ── Sync mission status (lightweight persistence, not decisions) ── - if (!isStepCompletionEvent && !isAttemptCompletionShadowEvent) { - void syncMissionFromRun(runId, event.reason); - } else if (isStepCompletionEvent) { - void syncMissionFromRun(runId, event.reason); - } - - // ──────────────────────────────────────────────────────────────────── - // COORDINATOR-OWNED: When the coordinator agent is alive, ALL decision - // logic is handled by it. We just route events — no deterministic - // transition handlers, quality gates, retry decisions, failure diagnosis, - // fan-out analysis, or intervention auto-resolution. - // ──────────────────────────────────────────────────────────────────── - if (coordinator) { - try { - routeEventToCoordinator(coordinator, event, { graph: getEventGraph() }); - } catch (routeError) { - logger.debug("ai_orchestrator.coordinator_v2_route_failed", { - runId, - reason: event.reason, - error: routeError instanceof Error ? routeError.message : String(routeError), - }); - } - } - return; - }, - - onSessionRuntimeSignal, - onAgentChatEvent, - - syncMissionFromRun, - - getWorkerStates, - evaluateWorkerPlan, - handleInterventionWithAI, - steerMission, - getModelCapabilities: () => getModelCapabilities(), - getTeamMembers: (tmArgs: { runId: string }) => getTeamMembersForRun(tmArgs.runId), - getTeamRuntimeState: (trArgs: { runId: string }) => getTeamRuntimeStateForRun(trArgs.runId), - finalizeRun, - resumeActiveTeamRuntimes, - routeUserMessageToCoordinator, - sendChat, - getChat, - listChatThreads, - getThreadMessages, - sendThreadMessage, - sendAgentMessage: sendAgentMessageWithMentions, - deliverMessageToAgent, - getGlobalChat, - getActiveAgents, - parseMentions, - getWorkerDigest, - listWorkerDigests, - getContextCheckpoint, - listLaneDecisions, - listArtifacts, - listWorkerCheckpoints, - getPromptInspector, - getPlanningPromptPreview, - getMissionMetrics, - setMissionMetricsConfig, - getExecutionPlanPreview, - getMissionStateDocument, - getRunView, - getAggregatedUsage, - getTeamManifest: (tmArgs: { runId: string }): TeamManifest | null => { - return runTeamManifests.get(tmArgs.runId) ?? null; - }, - getRecoveryLoopState: (rlArgs: { runId: string }): RecoveryLoopState | null => { - return runRecoveryLoopStates.get(rlArgs.runId) ?? null; - }, - runHealthSweep: (reason = "manual") => runHealthSweep(reason), - getMissionLogs, - exportMissionLogs, - setAgentChatService: (service: ReturnType | null) => { - agentChatService = service; - ctx.agentChatService = service; - }, - dispose: () => { - disposed = true; - disposedRef.current = true; - if (healthSweepTimer) { - clearInterval(healthSweepTimer); - healthSweepTimer = null; - healthSweepTimerRef.current = null; - } - // Cancel pending event-driven coordinator evaluations - for (const [rid, evalTimer] of pendingCoordinatorEvals.entries()) { - clearTimeout(evalTimer); - pendingCoordinatorEvals.delete(rid); - } - for (const [runId, timers] of runWatchdogTimers.entries()) { - for (const timer of timers) { - clearTimeout(timer); - } - runWatchdogTimers.delete(runId); - } - syncLocks.clear(); - syncInFlight.clear(); - workerStates.clear(); - activeSteeringDirectives.clear(); - runRuntimeProfiles.clear(); - chatMessages.clear(); - activeChatSessions.clear(); - chatTurnQueues.clear(); - activeHealthSweepRuns.clear(); - sessionRuntimeSignals.clear(); - attemptRuntimeTrackers.clear(); - sessionSignalQueues.clear(); - workerDeliveryThreadQueues.clear(); - workerDeliveryInterventionCooldowns.clear(); - runTeamManifests.clear(); - runRecoveryLoopStates.clear(); - teamRuntimeStates.clear(); - validationSystemSignalDedupe.clear(); - workerProgressChatState.clear(); - milestoneReadyNotificationSignatures.clear(); - callTypeConfigCache.clear(); - // Shutdown all coordinator agents - for (const [rid, agent] of coordinatorAgents.entries()) { - agent.shutdown(); - coordinatorAgents.delete(rid); - } - } - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts deleted file mode 100644 index 89f4411c3..000000000 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ /dev/null @@ -1,908 +0,0 @@ -import type { - OrchestratorExecutorAdapter, - OrchestratorExecutorStartArgs, - OrchestratorExecutorStartResult -} from "./orchestratorService"; -import type { OrchestratorWorkerRole, OrchestratorStep, OrchestratorExecutorKind, TerminalToolType, TeamRuntimeConfig } from "../../../shared/types"; -import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; -import { DEFAULT_CONTEXT_VIEW_POLICIES, SLASH_COMMAND_TRANSLATIONS } from "./orchestratorConstants"; - -// ───────────────────────────────────────────────────── -// Shared helpers -// ───────────────────────────────────────────────────── - -export function shellEscapeArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -export function shellInlineDecodedArg(value: string): string { - return `"$(node -e 'process.stdout.write(JSON.parse(process.argv[1]))' ${shellEscapeArg(JSON.stringify(value))})"`; -} - -export function compactText(value: string, maxChars = 220): string { - const normalized = value.replace(/\s+/g, " ").trim(); - if (normalized.length <= maxChars) return normalized; - return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; -} - -export type AdapterLaunchCommand = { - /** Human-readable preview stored with the session and metadata. */ - startupCommand: string; - /** Optional direct executable to launch instead of typing startupCommand into a shell. */ - command?: string; - args?: string[]; - env?: Record; -}; - -function normalizeAdapterLaunch(value: string | AdapterLaunchCommand): AdapterLaunchCommand { - if (typeof value === "string") return { startupCommand: value }; - return value; -} - -function ptyLaunchFields(launch: AdapterLaunchCommand): { - startupCommand: string; - command?: string; - args?: string[]; - env?: Record; -} { - return { - startupCommand: launch.startupCommand, - ...(launch.command ? { command: launch.command } : {}), - ...(launch.args ? { args: launch.args } : {}), - ...(launch.env ? { env: launch.env } : {}), - }; -} - -export function buildCompactPlanView(currentStep: OrchestratorStep, allSteps: OrchestratorStep[]): string { - if (!allSteps.length) return ""; - const stepIdToKey = new Map(allSteps.map((s) => [s.id, s.stepKey])); - const sorted = [...allSteps].sort((a, b) => a.stepIndex - b.stepIndex); - const lines: string[] = ["Mission Plan:"]; - for (const s of sorted) { - const isCurrentStep = s.id === currentStep.id; - let prefix: string; - switch (s.status) { - case "succeeded": - prefix = " [done]"; - break; - case "failed": - prefix = " [FAILED]"; - break; - case "canceled": - prefix = " [canceled]"; - break; - case "running": - prefix = isCurrentStep ? " >>> [running/YOU]" : " + [running]"; - break; - case "blocked": - prefix = " ! [blocked]"; - break; - case "ready": - prefix = " * [ready]"; - break; - default: - prefix = " -> [pending]"; - break; - } - let line = `${prefix} ${s.stepKey}`; - if (s.status === "running" && !isCurrentStep) { - line += " (parallel)"; - } - if (s.dependencyStepIds.length > 0) { - const depKeys = s.dependencyStepIds - .map((depId) => stepIdToKey.get(depId) ?? depId.slice(0, 8)) - .join(", "); - line += ` (depends on: ${depKeys})`; - } - lines.push(line); - } - return lines.join("\n"); -} - -// ───────────────────────────────────────────────────── -// Adapter configuration -// ───────────────────────────────────────────────────── - -export interface BaseAdapterConfig { - /** Executor kind identifier, e.g. "claude" or "codex". */ - executorKind: OrchestratorExecutorKind; - /** Session type for tracked sessions, e.g. "claude-orchestrated". */ - sessionType: TerminalToolType; - /** Build the startup command for a startup-command-override case. */ - buildOverrideCommand: (args: { prompt: string }) => string | AdapterLaunchCommand; - /** Build the full startup command from the assembled prompt + resolved config. */ - buildStartupCommand: (args: { - prompt: string; - model: string; - step: OrchestratorStep; - run: import("../../../shared/types").OrchestratorRun; - attempt: import("../../../shared/types").OrchestratorAttempt; - permissionConfig: OrchestratorExecutorStartArgs["permissionConfig"]; - teamRuntime?: TeamRuntimeConfig; - }) => string | AdapterLaunchCommand; - /** Build adapter-specific metadata to include in the accepted result. */ - buildAcceptedMetadata: (args: { - model: string; - step: OrchestratorStep; - permissionConfig: OrchestratorExecutorStartArgs["permissionConfig"]; - filePatterns: string[]; - steeringDirectiveCount: number; - promptLength: number; - reasoningEffort: string | undefined; - startupCommandPreview: string; - }) => Record; -} - -// ───────────────────────────────────────────────────── -// Shared prompt assembly -// ───────────────────────────────────────────────────── - -/** - * Builds the full prompt (system parts + user parts) from the executor start args. - * This is the shared logic extracted from both Claude and Codex adapters. - */ -export function buildFullPrompt( - args: OrchestratorExecutorStartArgs, - _executorKind?: OrchestratorExecutorKind, - opts?: { - workerRuntime?: "tracked_session" | "managed_chat" | "in_process"; - } -): { - prompt: string; - filePatterns: string[]; - steeringDirectiveCount: number; -} { - const { run, step } = args; - const workerRuntime = opts?.workerRuntime ?? "tracked_session"; - const hasMissionTooling = workerRuntime === "tracked_session"; - const hasManagedChatRuntime = workerRuntime === "managed_chat"; - - // 1. Build system prompt - const systemParts: string[] = []; - - const missionGoal = - typeof run.metadata?.missionGoal === "string" ? run.metadata.missionGoal.trim() : ""; - if (missionGoal) { - systemParts.push(`Mission goal: ${missionGoal}`); - } - - systemParts.push(`You are an ADE orchestrator worker executing step "${step.title}".`); - - // A. Identity block - { - const role = typeof step.metadata?.role === "string" ? step.metadata.role : step.stepKey; - const laneLabel = step.laneId ?? "unassigned"; - systemParts.push( - [ - `Role: ${role}`, - `Step: "${step.title}" (key: ${step.stepKey})`, - `Mission: ${missionGoal || "(no goal)"} (mission: ${run.missionId}, run: ${run.id})`, - `Lane: ${laneLabel}` - ].join("\n") - ); - } - - // A½. Worktree isolation constraint - { - const laneWorktreePath = typeof step.metadata?.laneWorktreePath === "string" - ? step.metadata.laneWorktreePath.trim() - : ""; - if (laneWorktreePath.length > 0) { - systemParts.push( - `WORKTREE ISOLATION: You are working in: ${laneWorktreePath}. All file edits MUST be made within this path. Do not read or write files outside this worktree directory.` - ); - } - } - - // B. Propulsion principle - systemParts.push( - "EXECUTION PROTOCOL: Execute immediately. Do not ask for confirmation or propose a plan and wait for approval. Do not summarize your instructions back. If you encounter a blocker you cannot work around, fail with a clear error message describing the blocker. Make the best decision you can and document your reasoning. The only time you should pause for human input is immediately after opening a blocking ADE question intervention that your phase policy explicitly allows." - ); - - // C. Compact plan view - { - const planView = buildCompactPlanView(step, args.allSteps); - if (planView) { - systemParts.push(planView); - } - } - - systemParts.push( - [ - "Work style:", - "- If you discover information relevant to other steps (API changes, schema updates, config requirements), include it in your output summary.", - "- If you hit a blocker you can work around safely, work around it and note what you did.", - "- Structure your output: lead with what you accomplished, then what you changed, then risks or notes for downstream steps.", - "- If your step depends on upstream work, check the handoff context before starting — don't redo completed work." - ].join("\n") - ); - - // Communication style for team-like updates - systemParts.push( - [ - "COMMUNICATION STYLE:", - "You are part of a team. When you make progress, share brief updates in natural, casual English.", - "Write like a teammate in a Slack channel — short blurbs, not formal reports.", - "Examples of good updates:", - '- "looking at the existing code first to understand the patterns"', - '- "implementing the auth middleware now, using JWT approach"', - '- "tests passing, moving on to the edge cases"', - '- "hit an issue with the import path, working around it"', - '- "done — changed 3 files, all tests green"', - "DO NOT dump full file contents, raw errors, or tool output into your updates.", - "Keep each update to 1-2 sentences max." - ].join("\n") - ); - - const instructions = - typeof step.metadata?.instructions === "string" ? step.metadata.instructions.trim() : ""; - if (instructions) { - systemParts.push(`Step instructions:\n${instructions}`); - } - - // Phase-level instructions from the phase card (supplements step instructions) - const phaseInstructions = - typeof step.metadata?.phaseInstructions === "string" ? step.metadata.phaseInstructions.trim() : ""; - if (phaseInstructions && phaseInstructions !== instructions) { - systemParts.push(`Phase-level guidance:\n${phaseInstructions}`); - } - - const requiresPlanApproval = - step.metadata?.requiresPlanApproval === true || step.metadata?.coordinationPattern === "plan_then_implement"; - const readOnlyExecution = step.metadata?.readOnlyExecution === true || requiresPlanApproval; - const phaseAskQuestionsRaw = - step.metadata?.phaseAskQuestions && typeof step.metadata.phaseAskQuestions === "object" && !Array.isArray(step.metadata.phaseAskQuestions) - ? step.metadata.phaseAskQuestions as Record - : null; - const phaseAllowsQuestions = phaseAskQuestionsRaw?.enabled === true; - const phaseRequiresQuestionBeforeExit = phaseAskQuestionsRaw?.requiredBeforeExit === true; - const phaseMaxQuestionsRaw = Number(phaseAskQuestionsRaw?.maxQuestions ?? Number.NaN); - const phaseMaxQuestions = Number.isFinite(phaseMaxQuestionsRaw) - ? Math.max(1, Math.min(10, Math.floor(phaseMaxQuestionsRaw))) - : null; - const answeredPlannerQuestionRaw = - step.metadata?.lastAnsweredPlannerQuestion - && typeof step.metadata.lastAnsweredPlannerQuestion === "object" - && !Array.isArray(step.metadata.lastAnsweredPlannerQuestion) - ? step.metadata.lastAnsweredPlannerQuestion as Record - : null; - const answeredPlannerQuestion = - typeof answeredPlannerQuestionRaw?.question === "string" && answeredPlannerQuestionRaw.question.trim().length > 0 - ? answeredPlannerQuestionRaw.question.trim() - : ""; - const answeredPlannerAnswer = - typeof answeredPlannerQuestionRaw?.answer === "string" && answeredPlannerQuestionRaw.answer.trim().length > 0 - ? answeredPlannerQuestionRaw.answer.trim() - : ""; - const phaseLabel = - typeof step.metadata?.phaseName === "string" && step.metadata.phaseName.trim().length > 0 - ? step.metadata.phaseName.trim() - : typeof step.metadata?.phaseKey === "string" && step.metadata.phaseKey.trim().length > 0 - ? step.metadata.phaseKey.trim() - : "this"; - if (readOnlyExecution) { - systemParts.push( - "IMPORTANT: This step is READ-ONLY. Do NOT modify files, stage changes, or run write operations. Research, review, and return findings or a plan only." - ); - } - - const managedChatQuestionInstruction = [ - "Use ADE's native chat question UI for blocking user input:", - "- Codex: call `request_user_input` with 1-3 structured questions, options, and a freeform/other answer when useful.", - "- Claude: use the AskUserQuestion / ask_user flow.", - "- OpenCode, Cursor, or Droid: use the provider's question mechanism so ADE can render the same inline question card.", - ].join(" "); - const activeQuestionInstruction = (() => { - if (phaseRequiresQuestionBeforeExit && answeredPlannerAnswer.length === 0) { - if (hasMissionTooling) { - return "- This planning phase requires at least one blocking user question before you return a successful plan. Use `ask_user` before `report_result`."; - } - if (hasManagedChatRuntime) { - return `- This planning phase requires at least one blocking user question before you return a successful plan. ${managedChatQuestionInstruction}`; - } - return "- This planning phase requires clarification, but this worker has no live question channel. Return a blocked final response that clearly names the missing user input."; - } - if (hasMissionTooling) { - return "- If you truly need clarification, use `ask_user` yourself rather than asking the coordinator to ask on your behalf."; - } - if (hasManagedChatRuntime) { - return `- If you truly need clarification, ask it yourself through the ADE chat question UI rather than asking the coordinator to ask on your behalf. ${managedChatQuestionInstruction}`; - } - return "- If you truly need clarification, state the narrow question in your final response instead of pretending you asked it."; - })(); - - systemParts.push( - phaseAllowsQuestions - ? [ - `PHASE QUESTION POLICY (${phaseLabel.toUpperCase()}):`, - "- You own clarification for this phase while this step is active.", - activeQuestionInstruction, - `- If you open a question, bundle related points into one intervention${phaseMaxQuestions ? ` and keep the total rounds for this step within ${phaseMaxQuestions}` : ""}.`, - "- After opening a blocking question, stop and wait. Do not continue execution, speculate in the transcript, or ask for the same input twice.", - ].join("\n") - : [ - `PHASE QUESTION POLICY (${phaseLabel.toUpperCase()}):`, - "- Ask Questions is disabled for this phase.", - "- Do not open `ask_user`, `request_user_input`, or any other ADE chat question here unless the runtime itself opens a separate intervention for delivery or policy recovery.", - "- Proceed with the best grounded assumption you can and document it in your result.", - ].join("\n") - ); - - if (answeredPlannerAnswer.length > 0) { - systemParts.push( - [ - "PLANNING QUESTION ALREADY ANSWERED:", - answeredPlannerQuestion.length > 0 ? `- Question: ${compactText(answeredPlannerQuestion, 360)}` : null, - `- User answer: ${compactText(answeredPlannerAnswer, 500)}`, - "- The clarification round for this planning phase is already consumed. Do not call `ask_user`, do not call `request_user_input`, and do not ask another natural-language clarification.", - hasMissionTooling - ? "- Use the answer above as a hard planning input and return `report_result.plan.markdown` now." - : hasManagedChatRuntime - ? "- Use the answer above as a hard planning input and return `### report_result` with `plan.markdown` now." - : "- Use the answer above as a hard planning input and return the plan now." - ].filter((line): line is string => typeof line === "string").join("\n") - ); - } - - // Planning-specific instructions for planning steps - { - const stepType = typeof step.metadata?.stepType === "string" ? step.metadata.stepType.trim().toLowerCase() : ""; - const isPlanningStep = stepType === "planning" || stepType === "analysis"; - if (isPlanningStep) { - systemParts.push( - [ - "PLANNING ARTIFACTS:", - "- This planning step is inspect-only. Do not create directories or write plan files yourself.", - "- Do NOT use ExitPlanMode or any provider-native plan approval flow.", - hasMissionTooling - ? "- Return your plan directly via `report_result`." - : "- Return your plan directly in the final assistant response under a `### report_result` heading.", - hasMissionTooling - ? "- Your `report_result` payload must include a first-class `plan` object with markdown content plus summary metadata." - : "- Your `### report_result` section must include a first-class `plan.markdown` field plus summary metadata.", - "- ADE will persist the canonical mission plan artifact after you complete successfully.", - hasMissionTooling - ? "- If you need clarification from the user and phase policy allows it, use `ask_user` to surface structured questions yourself." - : hasManagedChatRuntime - ? "- If you need clarification from the user and phase policy allows it, use ADE's native chat question UI yourself." - : "- If you need clarification from the user, return blocked and include the exact question." - ].join("\n") - ); - } - } - - { - const phaseValidation = step.metadata?.phaseValidation; - const evidenceRequirements = phaseValidation && typeof phaseValidation === "object" && !Array.isArray(phaseValidation) && Array.isArray((phaseValidation as Record).evidenceRequirements) - ? ((phaseValidation as Record).evidenceRequirements as unknown[]) - .map((entry) => typeof entry === "string" ? entry.trim() : "") - .filter(Boolean) - : []; - const hardProofRequirements = evidenceRequirements.filter((entry) => - entry === "screenshot" - || entry === "browser_verification" - || entry === "browser_trace" - || entry === "video_recording" - || entry === "console_logs" - ); - if (hardProofRequirements.length > 0 && hasMissionTooling) { - systemParts.push( - [ - "PROOF CAPTURE:", - `- This step requires proof artifacts: ${hardProofRequirements.join(", ")}.`, - "- Treat generic proof as visual evidence first: screenshots/images, screen recordings, browser screenshots, or browser traces. Do not substitute console logs for visual proof when the user asks to see proof.", - "- Console logs are supporting diagnostics. Use `console_logs` alone only when that exact requirement is listed or the user explicitly asks for logs.", - "- Prefer external computer-use backends first. Use `get_computer_use_backend_status` to see what ADE can ingest and prefer approved external tools such as `ext.*` backends or external CLIs like agent-browser when available.", - "- After an external backend produces proof, call `ingest_computer_use_artifacts` so ADE can normalize, store, link, and publish the resulting evidence.", - "- Use ADE-local tools (`get_environment_info`, `launch_app`, `interact_gui`, `screenshot_environment`, `record_environment`) only as fallback compatibility support when an external backend is not available for the step.", - "- Do not assume ADE inferred proof automatically. Register or attach the resulting artifact URIs explicitly.", - ].join("\n") - ); - } - } - - systemParts.push( - hasMissionTooling - ? [ - "RESULT REPORTING:", - "- Use `report_status` for short progress updates when you make meaningful progress or hit a blocker.", - "- Before you exit, ALWAYS call `report_result` with your outcome, summary, filesChanged, and testsRun fields filled in as accurately as possible.", - "- Planning steps must also include `plan.markdown` in `report_result`.", - ...(readOnlyExecution - ? [ - "- This step cannot write files. Do NOT attempt `.ade/checkpoints/...` or `.ade/step-output-...md` writes.", - "- Put your findings, plan, warnings, and suggested next steps into `report_result` instead." - ] - : [ - "- After calling `report_result`, also write the checkpoint and step-output files described below." - ]) - ].join("\n") - : hasManagedChatRuntime - ? [ - "RESULT REPORTING:", - "- Your full ADE chat transcript is captured and visible in the mission Conversations tab.", - "- Before you exit, include a final `### report_result` section with outcome, summary, filesChanged, and testsRun fields filled in as accurately as possible.", - "- Planning steps must include `plan.markdown` in that `### report_result` section.", - ...(readOnlyExecution - ? [ - "- This step cannot write files. Do NOT attempt `.ade/checkpoints/...` or `.ade/step-output-...md` writes.", - "- Put your findings, plan, warnings, and suggested next steps into the final `### report_result` section instead." - ] - : [ - "- After the final `### report_result`, also write the checkpoint and step-output files described below when the step is allowed to write." - ]) - ].join("\n") - : [ - "RESULT REPORTING:", - "- This worker is running in-process. You do NOT have ADE mission-control tools such as `report_status`, `report_result`, `get_pending_messages`, or `get_run_graph`.", - "- Return your outcome directly in the final assistant response.", - "- Format the final response with these headings when relevant: Accomplished, Changed Files, Tests Run, Risks / Notes.", - ...(readOnlyExecution - ? [ - "- This step is read-only. Do not claim any file changes unless you actually made them." - ] - : []) - ].join("\n") - ); - - const steeringDirectives = Array.isArray(step.metadata?.steeringDirectives) - ? (step.metadata.steeringDirectives as unknown[]) - .map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; - const record = entry as Record; - const directive = typeof record.directive === "string" ? compactText(record.directive, 240) : ""; - if (!directive.length) return null; - const priorityRaw = typeof record.priority === "string" ? record.priority.trim() : ""; - const priority = priorityRaw === "instruction" || priorityRaw === "override" ? priorityRaw : "suggestion"; - const targetStepKey = typeof record.targetStepKey === "string" ? record.targetStepKey.trim() : ""; - return { - directive, - priority, - targetStepKey - }; - }) - .filter((entry): entry is { directive: string; priority: "suggestion" | "instruction" | "override"; targetStepKey: string } => Boolean(entry)) - : []; - if (steeringDirectives.length > 0) { - const recentSteering = steeringDirectives.slice(-6); - systemParts.push( - [ - "Active operator steering directives (highest priority first):", - ...recentSteering.map( - (entry) => - `- [${entry.priority}] ${entry.directive}${entry.targetStepKey ? ` (target: ${entry.targetStepKey})` : ""}` - ), - "Apply these directives unless a higher-priority safety/policy constraint blocks them." - ].join("\n") - ); - } - - // File ownership fence - const filePatternsFromMetadata = Array.isArray(step.metadata?.filePatterns) - ? (step.metadata.filePatterns as unknown[]) - .map((p) => String(p ?? "").trim()) - .filter(Boolean) - : []; - const filePatternsFromClaimScopes = (() => { - const policy = step.metadata?.policy; - if (!policy || typeof policy !== "object" || Array.isArray(policy)) return [] as string[]; - const claimScopes = Array.isArray((policy as Record).claimScopes) - ? ((policy as Record).claimScopes as unknown[]) - : []; - return claimScopes - .map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return ""; - const scope = entry as Record; - const scopeKind = String(scope.scopeKind ?? "").trim(); - if (scopeKind !== "file") return ""; - let scopeValue = String(scope.scopeValue ?? "").trim(); - if (scopeValue.startsWith("pattern:")) scopeValue = scopeValue.slice("pattern:".length); - if (scopeValue.startsWith("glob:")) scopeValue = scopeValue.slice("glob:".length); - return scopeValue.trim(); - }) - .filter(Boolean); - })(); - const filePatterns = [...new Set([...filePatternsFromMetadata, ...filePatternsFromClaimScopes])]; - if (filePatterns.length) { - systemParts.push( - `You are responsible for these files: ${filePatterns.join(", ")}. Do not modify files outside this scope.` - ); - } - - // Handoff context from upstream steps - const handoffSummaries = Array.isArray(step.metadata?.handoffSummaries) - ? (step.metadata.handoffSummaries as unknown[]) - .map((s) => String(s ?? "").trim()) - .filter(Boolean) - : []; - if (handoffSummaries.length) { - systemParts.push(`Context from upstream steps:\n${handoffSummaries.map((s) => `- ${s}`).join("\n")}`); - } - - // Advisory dependency note - if (step.joinPolicy === "advisory" && step.dependencyStepIds.length > 0) { - systemParts.push( - "Note: Your upstream dependencies are advisory (non-blocking). Some upstream steps may still be running. Proceed with your best understanding and note any assumptions." - ); - } - - // Recovery context for retry attempts - if (args.previousCheckpoint || args.previousAttemptSummary) { - const recoveryParts: string[] = ["RECOVERY CONTEXT — PREVIOUS PROGRESS:"]; - recoveryParts.push("Your previous attempt on this step was interrupted."); - if (args.previousCheckpoint) { - recoveryParts.push( - "Here is your checkpoint from before the interruption:", - "", - "---", - args.previousCheckpoint, - "---", - "" - ); - } - if (args.previousAttemptSummary) { - recoveryParts.push( - "Previous attempt outcome:", - args.previousAttemptSummary, - "" - ); - } - // Include AI diagnosis if the orchestrator diagnosed the failure - const recoveryDiagnosis = step.metadata?.lastRecoveryDiagnosis; - if (recoveryDiagnosis && typeof recoveryDiagnosis === "object" && !Array.isArray(recoveryDiagnosis)) { - const diag = recoveryDiagnosis as Record; - const classification = typeof diag.classification === "string" ? diag.classification : ""; - if (classification) { - recoveryParts.push( - `Orchestrator diagnosis of previous failure: ${classification}`, - "The steering directives above contain specific recovery guidance from the orchestrator. Follow them." - ); - } - } - recoveryParts.push( - "Resume from where you left off. Do not redo work that was already completed. Check the state of files mentioned in the checkpoint to verify what was actually saved." - ); - systemParts.push(recoveryParts.join("\n")); - } - - // Inject translated slash command prompt as instructions - const rawStartup = typeof step.metadata?.startupCommand === "string" ? step.metadata.startupCommand.trim() : ""; - const slashBase = rawStartup.split(/\s/)[0]; - const slashTranslation = slashBase ? SLASH_COMMAND_TRANSLATIONS[slashBase] : undefined; - if (slashTranslation) { - systemParts.push(`Slash command instructions:\n${slashTranslation.prompt}`); - } - - // Apply role-specific context view - const workerRole = typeof step.metadata?.role === "string" ? step.metadata.role as OrchestratorWorkerRole : null; - const contextView = workerRole ? DEFAULT_CONTEXT_VIEW_POLICIES[ - workerRole === "code_review" ? "review" : - workerRole === "test_review" ? "test_review" : - "implementation" - ] : null; - - if (contextView?.readOnly) { - systemParts.push("IMPORTANT: You are in a READ-ONLY review role. Do NOT modify any files. Only analyze and provide feedback on the code/tests you are reviewing."); - } - - // ADE self-awareness - systemParts.push( - [ - "You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions.", - "Project orientation: do not assume generated PRD or architecture summaries exist. When you need product or architecture context, inspect the repo directly: start with AGENTS.md, README.md, docs/, package manifests, and the relevant source files in your lane worktree." - ].join("\n") - ); - - // ADE collaboration tools - if (hasMissionTooling) { - systemParts.push( - [ - ADE_CLI_AGENT_GUIDANCE, - "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", - "Key actions available:", - "- get_worker_states: See all peer workers in your run and their current status", - "- get_run_graph: See the full execution plan, step statuses, and dependencies", - "- get_mission: Get mission details and metadata", - "- get_pending_messages: Check for messages from the coordinator or peer workers", - "- get_timeline: See recent events in your run", - "- stream_events: Poll for new orchestrator events", - "From a shell worker, call these through the ADE CLI, for example `ade get_pending_messages --text`, `ade get_run_graph --text`, `ade get_worker_states --text`, or `ade message_worker --content \"...\" --text`.", - "Use get_pending_messages periodically to check for steering directives or peer communications." - ].join("\n") - ); - } else if (hasManagedChatRuntime) { - systemParts.push( - [ - "RUNTIME LIMITS:", - "- This worker runs as a managed ADE chat session. ADE captures the live transcript for the mission Conversations tab.", - "- You may receive user answers through the provider's native question UI when phase policy allows questions.", - "- Complete the current assignment end-to-end unless you explicitly open a blocking question and wait for the answer." - ].join("\n") - ); - } else { - systemParts.push( - [ - "RUNTIME LIMITS:", - "- This worker runs as a bounded in-process execution, not a tracked ADE session.", - "- You will not receive follow-up steering while this attempt is running.", - "- Treat the current prompt as the full assignment and complete it end-to-end in one pass." - ].join("\n") - ); - } - - // Team runtime capabilities - { - const teamRuntime = run.metadata && typeof run.metadata === "object" && !Array.isArray(run.metadata) - ? (run.metadata as Record).teamRuntime as TeamRuntimeConfig | undefined - : undefined; - - if (teamRuntime?.enabled && hasMissionTooling) { - systemParts.push( - [ - "TEAM RUNTIME (ACTIVE): You are part of an ADE agent team with shared task management.", - "- You can claim tasks from the shared task list", - "- You can send messages to other teammates via the coordinator", - "- You can report progress, blockers, and discoveries", - "- Focus on your claimed task — the coordinator manages task distribution", - "- When your task is done, report completion and the coordinator will assign more work or finalize the run" - ].join("\n") - ); - } else if (teamRuntime?.enabled) { - systemParts.push( - [ - "TEAM RUNTIME (ACTIVE): This mission is running with team semantics, but your worker is one-shot and not live-steerable.", - "- Finish the assignment in this prompt without waiting for re-assignment.", - "- Surface discoveries for sibling steps in your final response." - ].join("\n") - ); - } - } - - // D. Checkpoint instructions - if (!readOnlyExecution) { - const sanitizedStepKey = step.stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - systemParts.push( - [ - `PROGRESS CHECKPOINTING: After completing each significant unit of work (e.g., finishing a function, completing a file, passing a test), write a checkpoint file at \`.ade/checkpoints/${sanitizedStepKey}.md\` in your working directory with (create the \`.ade/checkpoints\` folder if needed):`, - "- What you have accomplished so far", - "- Files you have modified and why", - "- Key decisions you made and your reasoning", - "- What you plan to do next", - "- Any risks or concerns", - "", - "Update this file as you progress. This checkpoint will be used for recovery if your session is interrupted." - ].join("\n") - ); - } - - // Handoff summary guidance - systemParts.push( - [ - "Before finishing, write a HANDOFF SUMMARY (3-5 bullets):", - "1. What you accomplished (files created/modified)", - "2. What downstream steps need to know (API changes, new deps, config updates)", - "3. Any risks or known issues (edge cases not covered, flaky tests)" - ].join("\n") - ); - - // Durable step output file - if (!readOnlyExecution) { - 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}-attempt-${sanitizedAttemptIdForOutput}.md`, - "", - "The file MUST contain these sections:", - "## Summary", - "1-2 sentence description of what was accomplished.", - "", - "## Files Changed", - "Bulleted list of files created or modified.", - "", - "## Tests", - "Test results if any tests were run (passed/failed/skipped counts).", - "", - "## Validation", - "Any validation performed and results.", - "", - "## Warnings", - "Any issues, concerns, or risks discovered.", - "", - "## Next Steps", - "Suggested follow-up work if applicable.", - "", - "This file is your durable output record. The orchestrator reads it to understand what you accomplished, especially after context compaction when conversation history is lost." - ].join("\n") - ); - } - - // Compaction context — preserved across context summarization - { - const completedSteps = args.allSteps.filter((s) => s.status === "succeeded").length; - const totalSteps = args.allSteps.length; - const claimScopes = Array.isArray(step.metadata?.claimScopes) - ? (step.metadata.claimScopes as Array<{ scopeValue?: string }>) - .map((c) => c.scopeValue ?? "") - .filter(Boolean) - .join(", ") - : filePatterns.join(", "); - systemParts.push( - [ - "## COMPACTION CONTEXT (preserve across context summarization)", - `- Mission: "${missionGoal.slice(0, 200)}"`, - `- Your step: "${step.metadata?.stepKey ?? step.stepKey}" (${step.title})`, - `- Files you own: ${claimScopes || "none"}`, - `- Run progress: ${completedSteps}/${totalSteps} steps complete`, - "When your context is summarized/compacted, preserve this section and any important discoveries." - ].join("\n") - ); - } - - // 2. Build user prompt from context snapshot - const userParts: string[] = []; - - if (args.laneExport) { - userParts.push(`--- Lane context ---\n${args.laneExport.content}`); - } - userParts.push(`--- Project context ---\n${args.projectExport.content}`); - - if (args.fullDocs.length) { - for (const doc of args.fullDocs) { - userParts.push(`--- Doc: ${doc.path}${doc.truncated ? " (truncated)" : ""} ---\n${doc.content}`); - } - } else if (args.docsRefs.length) { - userParts.push( - `Referenced docs: ${args.docsRefs.map((r) => `${r.path} (${r.sha256.slice(0, 8)})`).join(", ")}` - ); - } - - const prompt = [systemParts.join("\n\n"), userParts.join("\n\n")].join("\n\n"); - - return { prompt, filePatterns, steeringDirectiveCount: steeringDirectives.length }; -} - -// ───────────────────────────────────────────────────── -// Factory -// ───────────────────────────────────────────────────── - -/** - * Creates an OrchestratorExecutorAdapter from a BaseAdapterConfig. - * Encapsulates all shared logic (prompt building, session creation, - * slash command handling, etc.) while delegating CLI-specific details - * to the config. - */ -export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): OrchestratorExecutorAdapter { - const { - executorKind, - sessionType, - buildOverrideCommand, - buildStartupCommand, - buildAcceptedMetadata - } = config; - - return { - kind: executorKind, - async start(args: OrchestratorExecutorStartArgs): Promise { - const { run, step, attempt } = args; - - if (!step.laneId) { - return { - status: "failed", - errorClass: "policy", - errorMessage: `${executorKind} executor requires step.laneId to create tracked sessions.` - }; - } - - try { - // 0a. Translate slash commands to proper prompts - const rawStartup = typeof step.metadata?.startupCommand === "string" ? step.metadata.startupCommand.trim() : ""; - const slashBase = rawStartup.split(/\s/)[0]; - const slashTranslation = slashBase ? SLASH_COMMAND_TRANSLATIONS[slashBase] : undefined; - - // 0b. Check for startup command override from policy (skip if slash translation applies) - const startupCommandOverride = - !slashTranslation && typeof step.metadata?.startupCommand === "string" && step.metadata.startupCommand.trim().length - ? step.metadata.startupCommand.trim() - : null; - - if (startupCommandOverride) { - const overridePrompt = [ADE_CLI_AGENT_GUIDANCE, startupCommandOverride].join("\n\n"); - const launch = normalizeAdapterLaunch(buildOverrideCommand({ prompt: overridePrompt })); - // Use the startup command directly as the prompt - const session = await args.createTrackedSession({ - laneId: step.laneId, - toolType: sessionType, - title: `[Orchestrator] ${step.title}`, - ...ptyLaunchFields(launch), - cols: 120, - rows: 40 - }); - - return { - status: "accepted", - sessionId: session.sessionId, - metadata: { - adapterKind: executorKind, - startupCommandOverride: true, - promptLength: overridePrompt.length, - startupCommandPreview: overridePrompt.slice(0, 320) - } - }; - } - - // 1-2. Build full prompt (shared logic, executor-aware) - const { prompt, filePatterns, steeringDirectiveCount } = buildFullPrompt(args, executorKind); - - // 3. Determine model (strict cutover: modelId is required) - const model = typeof step.metadata?.modelId === "string" ? step.metadata.modelId.trim() : ""; - if (!model.length) { - return { - status: "failed", - errorClass: "policy", - errorMessage: `Step '${step.stepKey}' is missing required metadata.modelId for ${executorKind} execution.` - }; - } - - // 4. Construct startup command (adapter-specific) - const teamRuntime = run.metadata && typeof run.metadata === "object" && !Array.isArray(run.metadata) - ? (run.metadata as Record).teamRuntime as TeamRuntimeConfig | undefined - : undefined; - const launch = normalizeAdapterLaunch(buildStartupCommand({ - prompt, - model, - step, - run, - attempt, - permissionConfig: args.permissionConfig, - teamRuntime - })); - - // 5. Create tracked session - const session = await args.createTrackedSession({ - laneId: step.laneId, - toolType: sessionType, - title: `[Orchestrator] ${step.title}`, - ...ptyLaunchFields(launch), - cols: 120, - rows: 40 - }); - - // 6. Resolve reasoning effort from step metadata - const reasoningEffort = - typeof step.metadata?.reasoningEffort === "string" && step.metadata.reasoningEffort.trim().length - ? step.metadata.reasoningEffort.trim() - : undefined; - - // 7. Return accepted with adapter-specific metadata - return { - status: "accepted", - sessionId: session.sessionId, - metadata: buildAcceptedMetadata({ - model, - step, - permissionConfig: args.permissionConfig, - filePatterns, - steeringDirectiveCount, - promptLength: prompt.length, - reasoningEffort, - startupCommandPreview: launch.startupCommand.slice(0, 320) - }) - }; - } catch (error) { - return { - status: "failed", - errorClass: "executor_failure", - errorMessage: error instanceof Error ? error.message : String(error), - metadata: { - adapterKind: executorKind, - adapterState: "start_failed" - } - }; - } - } - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/chatMessageService.ts b/apps/desktop/src/main/services/orchestrator/chatMessageService.ts deleted file mode 100644 index 9f9ce0d8e..000000000 --- a/apps/desktop/src/main/services/orchestrator/chatMessageService.ts +++ /dev/null @@ -1,2162 +0,0 @@ -/** - * chatMessageService.ts - * - * Messaging: sendChat, getChat, listChatThreads, getThreadMessages, - * sendThreadMessage, sendAgentMessage, deliverMessageToAgent, - * getGlobalChat, parseMentions, getActiveAgents, replayQueuedWorkerMessages, - * chat session management, message persistence, enqueueChatResponse. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import { randomUUID } from "node:crypto"; -import type { - OrchestratorContext, - OrchestratorChatSessionState, - OrchestratorChatMessage, - OrchestratorChatThread, - OrchestratorChatTarget, - OrchestratorChatVisibilityMode, - OrchestratorChatDeliveryState, - OrchestratorChatThreadType, -} from "./orchestratorContext"; -import { - nowIso, - isRecord, - parseChatMessage, - parseChatSessionState, - parseChatTarget, - sanitizeChatTarget, - teammateThreadIdentity, - workerThreadIdentity, - deriveThreadTitle, - toOptionalString, - clipTextForContext, - normalizeChatVisibility, - normalizeChatDeliveryState, - normalizeThreadType, - clampLimit, - missionThreadId, - parseJsonRecord, - ORCHESTRATOR_CHAT_METADATA_KEY, - ORCHESTRATOR_CHAT_SESSION_METADATA_KEY, - MAX_PERSISTED_CHAT_MESSAGES, - MAX_CHAT_CONTEXT_CHARS, - MAX_CHAT_CONTEXT_MESSAGES, - MAX_CHAT_LINE_CHARS, - DEFAULT_CHAT_VISIBILITY, - DEFAULT_CHAT_DELIVERY, - DEFAULT_WORKER_CHAT_VISIBILITY, - DEFAULT_THREAD_STATUS, - DEFAULT_CHAT_THREAD_TITLE, - CONTEXT_CHECKPOINT_CHAT_THRESHOLD, - WORKER_MESSAGE_RETRY_BACKOFF_BASE_MS, - WORKER_MESSAGE_RETRY_BACKOFF_MAX_MS, - STEERING_DIRECTIVES_METADATA_KEY, - MAX_PERSISTED_STEERING_DIRECTIVES, - parseSteeringDirective, -} from "./orchestratorContext"; -import type { - AgentChatFileRef, - SendOrchestratorChatArgs, - GetOrchestratorChatArgs, - ListOrchestratorChatThreadsArgs, - GetOrchestratorThreadMessagesArgs, - SendOrchestratorThreadMessageArgs, - SendAgentMessageArgs, - GetGlobalChatArgs, - GetActiveAgentsArgs, - ActiveAgentInfo, - OrchestratorRunGraph, - OrchestratorThreadEvent, - OrchestratorMissionThreadUserMessageStructuredStream, -} from "../../../shared/types"; - -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function normalizeThreadAttachments(value: unknown): AgentChatFileRef[] { - if (!Array.isArray(value)) return []; - const deduped = new Map(); - for (const entry of value) { - if (!isRecord(entry)) continue; - const path = readString(entry.path); - const type = readString(entry.type); - if (!path || (type !== "file" && type !== "image")) continue; - deduped.set(path, { path, type }); - } - return [...deduped.values()]; -} - -function buildAttachmentContextSuffix(attachments: AgentChatFileRef[]): string { - if (!attachments.length) return ""; - return `\n\nAttached context:\n${attachments.map((file) => `- ${file.type}: ${file.path}`).join("\n")}`; -} - -function buildStructuredUserMessageMetadata(args: { - content: string; - attachments: AgentChatFileRef[]; - metadata?: Record | null; -}): Record | null { - const base = isRecord(args.metadata) ? { ...args.metadata } : {}; - if (!args.attachments.length) { - return Object.keys(base).length > 0 ? base : null; - } - - const existingStructured = isRecord(base.structuredStream) ? { ...base.structuredStream } : {}; - const structuredStream: OrchestratorMissionThreadUserMessageStructuredStream & Record = { - ...existingStructured, - kind: "user_message", - text: args.content, - attachments: args.attachments, - }; - base.structuredStream = structuredStream; - return base; -} - -function shouldPreserveCoordinatorPromptForRouting(content: string): boolean { - const normalized = content.trim().toLowerCase(); - return normalized === "status" - || normalized === "progress" - || normalized === "heartbeat" - || normalized === "what's happening" - || normalized === "what is happening"; -} - -function looksLikeLowSignalMissionNoise(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed.length) return true; - if (/^streaming(?:\.\.\.)?$/i.test(trimmed)) return true; - if (/^usage$/i.test(trimmed)) return true; - if (/^[\-dlcbps][rwx\-@+]{8,}/i.test(trimmed)) return true; - if (/^[A-Z0-9 .:_()/-]{24,}$/.test(trimmed)) return true; - // Single-token strings under 24 chars that look like identifiers or noise - // tokens rather than prose. Allow strings with sentence-ending punctuation - // (e.g. "Done.", "Error!") since those are genuine assistant responses. - if (!/\s/.test(trimmed) && trimmed.length < 24 && !/[.!?]/.test(trimmed)) return true; - if (/^[A-Za-z]+$/.test(trimmed) && trimmed.length < 24) return true; - return false; -} - -function shouldCountAsMissionAttentionMessage(message: OrchestratorChatMessage): boolean { - if (message.role === "user" || message.visibility === "metadata_only") return false; - const metadata = isRecord(message.metadata) ? message.metadata : null; - if (metadata?.missionChatMode === "thread_only") return false; - - const structured = isRecord(metadata?.structuredStream) ? metadata.structuredStream : null; - const kind = readString(structured?.kind); - const content = typeof message.content === "string" ? message.content : ""; - - if (kind === "plan" || kind === "approval_request" || kind === "user_message") return true; - if (kind === "text" || kind === "reasoning") return !looksLikeLowSignalMissionNoise(content); - if (kind === "status") { - const status = readString(structured?.status)?.toLowerCase() ?? ""; - const statusMessage = readString(structured?.message) ?? ""; - if (status === "failed" || status === "interrupted") return true; - return statusMessage.length > 0 && !looksLikeLowSignalMissionNoise(statusMessage); - } - if (kind === "error") { - const errorMessage = readString(structured?.message) ?? content; - return errorMessage.length > 0 && !looksLikeLowSignalMissionNoise(errorMessage); - } - if (kind) return false; - - return !looksLikeLowSignalMissionNoise(content); -} - -// ── Thread Event Emission ──────────────────────────────────────── - -export function emitThreadEvent( - ctx: OrchestratorContext, - event: Omit -): void { - if (ctx.disposed.current) return; - try { - ctx.onThreadEvent?.({ - ...event, - at: nowIso() - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.thread_event_emit_failed", { - type: event.type, - missionId: event.missionId, - threadId: event.threadId ?? null, - messageId: event.messageId ?? null, - error: error instanceof Error ? error.message : String(error) - }); - } -} - -// ── Mission Metadata Helpers ───────────────────────────────────── - -export function getMissionMetadata(ctx: OrchestratorContext, missionId: string): Record { - const row = ctx.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [missionId] - ); - if (!row?.metadata_json) return {}; - try { - return JSON.parse(row.metadata_json) as Record; - } catch { - return {}; - } -} - -export function updateMissionMetadata( - ctx: OrchestratorContext, - missionId: string, - mutate: (metadata: Record) => void -): void { - const metadata = getMissionMetadata(ctx, missionId); - mutate(metadata); - ctx.db.run( - `update missions set metadata_json = ?, updated_at = ? where id = ?`, - [JSON.stringify(metadata), nowIso(), missionId] - ); -} - -export function getMissionIdentity( - ctx: OrchestratorContext, - missionId: string -): { projectId: string; laneId: string | null } | null { - const row = ctx.db.get<{ project_id: string; lane_id: string | null }>( - `select project_id, lane_id from missions where id = ? limit 1`, - [missionId] - ); - if (!row?.project_id) return null; - return { - projectId: row.project_id, - laneId: row.lane_id ?? null - }; -} - -export function getMissionIdForRun(ctx: OrchestratorContext, runId: string): string | null { - const row = ctx.db.get<{ mission_id: string | null }>( - `select mission_id from orchestrator_runs where id = ? limit 1`, - [runId] - ); - return toOptionalString(row?.mission_id); -} - -export function getRunMetadata(ctx: OrchestratorContext, runId: string): Record { - const row = ctx.db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - if (!row?.metadata_json) return {}; - try { - const parsed = JSON.parse(row.metadata_json); - return isRecord(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -export function updateRunMetadata( - ctx: OrchestratorContext, - runId: string, - mutate: (metadata: Record) => void -): boolean { - const row = ctx.db.get<{ id: string; metadata_json: string | null }>( - `select id, metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - if (!row?.id) return false; - const metadata = (() => { - if (!row.metadata_json) return {} as Record; - try { - const parsed = JSON.parse(row.metadata_json); - return isRecord(parsed) ? parsed : {}; - } catch { - return {} as Record; - } - })(); - mutate(metadata); - ctx.db.run( - `update orchestrator_runs set metadata_json = ?, updated_at = ? where id = ?`, - [JSON.stringify(metadata), nowIso(), runId] - ); - return true; -} - -// ── Steering Directive Helpers ─────────────────────────────────── - -export function loadSteeringDirectivesFromMetadata( - ctx: OrchestratorContext, - missionId: string -): import("./orchestratorContext").UserSteeringDirective[] { - if (ctx.activeSteeringDirectives.has(missionId)) { - return ctx.activeSteeringDirectives.get(missionId) ?? []; - } - const metadata = getMissionMetadata(ctx, missionId); - const stored = Array.isArray(metadata[STEERING_DIRECTIVES_METADATA_KEY]) - ? metadata[STEERING_DIRECTIVES_METADATA_KEY] as unknown[] - : []; - const parsed = stored - .map((entry) => parseSteeringDirective(entry, missionId)) - .filter((d): d is import("./orchestratorContext").UserSteeringDirective => d !== null) - .slice(-MAX_PERSISTED_STEERING_DIRECTIVES); - if (parsed.length > 0) { - ctx.activeSteeringDirectives.set(missionId, parsed); - } - return parsed; -} - -export function loadChatMessagesFromMetadata( - ctx: OrchestratorContext, - missionId: string -): OrchestratorChatMessage[] { - if (ctx.chatMessages.has(missionId)) { - return ctx.chatMessages.get(missionId) ?? []; - } - const metadata = getMissionMetadata(ctx, missionId); - const stored = Array.isArray(metadata[ORCHESTRATOR_CHAT_METADATA_KEY]) - ? metadata[ORCHESTRATOR_CHAT_METADATA_KEY] as unknown[] - : []; - const parsed = stored - .map((entry) => parseChatMessage(entry, missionId)) - .filter((msg): msg is OrchestratorChatMessage => msg !== null) - .slice(-MAX_PERSISTED_CHAT_MESSAGES); - if (parsed.length > 0) { - ctx.chatMessages.set(missionId, parsed); - } - return parsed; -} - -export function loadChatSessionStateFromMetadata( - ctx: OrchestratorContext, - missionId: string -): OrchestratorChatSessionState | null { - if (ctx.activeChatSessions.has(missionId)) { - return ctx.activeChatSessions.get(missionId) ?? null; - } - const metadata = getMissionMetadata(ctx, missionId); - const parsed = parseChatSessionState(metadata[ORCHESTRATOR_CHAT_SESSION_METADATA_KEY]); - if (parsed) { - ctx.activeChatSessions.set(missionId, parsed); - return parsed; - } - return null; -} - -// ── Chat Context Builders ──────────────────────────────────────── - -export function formatRecentChatContext( - messages: OrchestratorChatMessage[], - limit = MAX_CHAT_CONTEXT_MESSAGES -): string { - const recent = messages.slice(-limit); - if (!recent.length) return ""; - const lines = recent.map((entry) => { - const role = entry.role === "user" ? "User" : entry.role === "worker" ? "Worker" : "Orchestrator"; - const compact = clipTextForContext( - entry.content.replace(/\s+/g, " ").trim(), - MAX_CHAT_LINE_CHARS - ); - return `- ${role}: ${compact}`; - }); - const rendered = ["Recent mission chat:", ...lines, ""].join("\n"); - return clipTextForContext(rendered, MAX_CHAT_CONTEXT_CHARS); -} - -// ── Orchestrator Message Emission ──────────────────────────────── - -export function formatOrchestratorContent( - content: string, - stepKey?: string | null, - metadata?: Record | null -): string { - if (metadata?.role === "coordinator") return content; - - let formatted = content; - formatted = formatted.replace(/```json[\s\S]*?```/g, "[json data]"); - formatted = formatted.replace(/```[\s\S]*?```/g, "[code block]"); - formatted = formatted.replace(/^[-+]{3}\s.*$/gm, "").replace(/^@@.*@@.*$/gm, ""); - formatted = formatted.replace(/\n{3,}/g, "\n\n").trim(); - - if (formatted.length > 200) { - formatted = `${formatted.slice(0, 197).trimEnd()}...`; - } - - if (stepKey) { - formatted = `[${stepKey}] ${formatted}`; - } - - return formatted; -} - -export function emitOrchestratorMessage( - _ctx: OrchestratorContext, - missionId: string, - content: string, - stepKey?: string | null, - metadata?: Record | null, - deps?: { - appendChatMessage: (message: OrchestratorChatMessage) => OrchestratorChatMessage; - } -): OrchestratorChatMessage { - const formattedContent = formatOrchestratorContent(content, stepKey, metadata); - const msg: OrchestratorChatMessage = { - id: randomUUID(), - missionId, - role: "orchestrator", - content: formattedContent, - timestamp: new Date().toISOString(), - stepKey: stepKey ?? null, - metadata: metadata ? { ...metadata, rawContent: content !== formattedContent ? content : undefined } : null - }; - if (deps?.appendChatMessage) { - return deps.appendChatMessage(msg); - } - return msg; -} - -// ── Mention Parsing ────────────────────────────────────────────── - -export function parseMentions(content: string): { mentions: string[]; cleanContent: string } { - const mentionPattern = /@([\w-]+)/g; - const mentions: string[] = []; - let match: RegExpExecArray | null; - while ((match = mentionPattern.exec(content)) !== null) { - mentions.push(match[1]); - } - const cleanContent = content.replace(/@[\w-]+/g, "").trim(); - return { mentions, cleanContent }; -} - -// ── Worker Delivery Helpers ────────────────────────────────────── - -export function normalizeDeliveryError(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) return error.message.trim(); - return String(error ?? "Unknown delivery failure"); -} - -export function isBusyDeliveryError(errorMessage: string): boolean { - const normalized = errorMessage.toLowerCase(); - return normalized.includes("turn is already active") - || normalized.includes("already active") - || normalized.includes("busy"); -} - -export function isNoActiveTurnError(errorMessage: string): boolean { - return /\bno active turn\b/i.test(errorMessage); -} - -export function computeWorkerRetryBackoffMs(retryCount: number): number { - const exponent = Math.max(0, retryCount - 1); - return Math.min( - WORKER_MESSAGE_RETRY_BACKOFF_MAX_MS, - WORKER_MESSAGE_RETRY_BACKOFF_BASE_MS * (2 ** exponent) - ); -} - -// ── Thread Row Type ────────────────────────────────────────────── - -export type ThreadRow = { - id: string; - mission_id: string; - thread_type: string; - title: string; - run_id: string | null; - step_id: string | null; - step_key: string | null; - attempt_id: string | null; - session_id: string | null; - lane_id: string | null; - status: string; - unread_count: number | null; - metadata_json: string | null; - created_at: string; - updated_at: string; -}; - -// ── Thread Management ──────────────────────────────────────────── - -export function parseThreadRow(row: ThreadRow): OrchestratorChatThread { - const metadata = parseJsonRecord(row.metadata_json); - const thread: OrchestratorChatThread = { - id: row.id, - missionId: row.mission_id, - threadType: normalizeThreadType(row.thread_type), - title: String(row.title ?? DEFAULT_CHAT_THREAD_TITLE), - runId: row.run_id ?? null, - stepId: row.step_id ?? null, - stepKey: row.step_key ?? null, - attemptId: row.attempt_id ?? null, - sessionId: row.session_id ?? null, - laneId: row.lane_id ?? null, - status: row.status === "closed" ? "closed" : "active", - unreadCount: Number.isFinite(Number(row.unread_count)) ? Math.max(0, Math.floor(Number(row.unread_count))) : 0, - createdAt: row.created_at, - updatedAt: row.updated_at, - metadata - }; - return thread; -} - -export function getThreadById(ctx: OrchestratorContext, missionId: string, threadId: string): OrchestratorChatThread | null { - const row = ctx.db.get( - ` - select - id, - mission_id, - thread_type, - title, - run_id, - step_id, - step_key, - attempt_id, - session_id, - lane_id, - status, - unread_count, - metadata_json, - created_at, - updated_at - from orchestrator_chat_threads - where mission_id = ? - and id = ? - limit 1 - `, - [missionId, threadId] - ); - if (!row) return null; - return parseThreadRow(row); -} - -export function upsertThread( - ctx: OrchestratorContext, - args: { - missionId: string; - threadId: string; - threadType: OrchestratorChatThreadType; - title: string; - target: OrchestratorChatTarget | null; - status?: "active" | "closed"; - metadata?: Record | null; - } -): OrchestratorChatThread { - const missionIdentity = getMissionIdentity(ctx, args.missionId); - if (!missionIdentity) { - throw new Error(`Mission not found: ${args.missionId}`); - } - const target = sanitizeChatTarget(args.target); - const runId = (target && "runId" in target ? target.runId : null) ?? null; - const isWorkerTarget = target?.kind === "worker"; - const isTeammateTarget = target?.kind === "teammate"; - const isCoordinatorTarget = target?.kind === "coordinator"; - const stepId = isWorkerTarget ? target.stepId ?? null : null; - const stepKey = isWorkerTarget ? target.stepKey ?? null : null; - const attemptId = isWorkerTarget ? target.attemptId ?? null : null; - const sessionId = isWorkerTarget || isTeammateTarget || isCoordinatorTarget - ? target.sessionId ?? null - : null; - const laneId = (isWorkerTarget || isCoordinatorTarget ? target.laneId : null) - ?? missionIdentity.laneId; - const metadataJson = - args.metadata - ? JSON.stringify(args.metadata) - : isTeammateTarget && target.teamMemberId - ? JSON.stringify({ teamMemberId: target.teamMemberId }) - : null; - const now = nowIso(); - ctx.db.run( - ` - insert into orchestrator_chat_threads( - id, - project_id, - mission_id, - thread_type, - title, - run_id, - step_id, - step_key, - attempt_id, - session_id, - lane_id, - status, - unread_count, - metadata_json, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?) - on conflict(id) do update set - thread_type = excluded.thread_type, - title = excluded.title, - run_id = excluded.run_id, - step_id = excluded.step_id, - step_key = excluded.step_key, - attempt_id = excluded.attempt_id, - session_id = coalesce(excluded.session_id, orchestrator_chat_threads.session_id), - lane_id = coalesce(excluded.lane_id, orchestrator_chat_threads.lane_id), - status = excluded.status, - metadata_json = excluded.metadata_json, - updated_at = excluded.updated_at - `, - [ - args.threadId, - missionIdentity.projectId, - args.missionId, - args.threadType, - args.title, - runId, - stepId, - stepKey, - attemptId, - sessionId, - laneId, - args.status ?? DEFAULT_THREAD_STATUS, - metadataJson, - now, - now - ] - ); - const nextThread = getThreadById(ctx, args.missionId, args.threadId) ?? { - id: args.threadId, - missionId: args.missionId, - threadType: args.threadType, - title: args.title, - runId, - stepId, - stepKey, - attemptId, - sessionId, - laneId, - status: args.status ?? "active", - unreadCount: 0, - createdAt: now, - updatedAt: now, - metadata: - args.metadata - ?? (isTeammateTarget && target.teamMemberId ? { teamMemberId: target.teamMemberId } : null) - }; - emitThreadEvent(ctx, { - type: "thread_updated", - missionId: args.missionId, - threadId: nextThread.id, - runId: nextThread.runId ?? null, - reason: "upsert_thread" - }); - return nextThread; -} - -export function ensureMissionThread(ctx: OrchestratorContext, missionId: string): OrchestratorChatThread { - const id = missionThreadId(missionId); - const existing = getThreadById(ctx, missionId, id); - if (existing) return existing; - return upsertThread(ctx, { - missionId, - threadId: id, - threadType: "coordinator", - title: DEFAULT_CHAT_THREAD_TITLE, - target: { - kind: "coordinator", - runId: null - } - }); -} - -export function ensureThreadForTarget( - ctx: OrchestratorContext, - args: { - missionId: string; - threadId?: string | null; - target?: OrchestratorChatTarget | null; - fallbackTitle?: string | null; - } -): OrchestratorChatThread { - const missionId = args.missionId; - const requestedThreadId = toOptionalString(args.threadId); - if (requestedThreadId) { - const existing = getThreadById(ctx, missionId, requestedThreadId); - if (existing) return existing; - const target = sanitizeChatTarget(args.target); - const threadType: OrchestratorChatThreadType = - target?.kind === "worker" - ? "worker" - : target?.kind === "teammate" - ? "teammate" - : "coordinator"; - return upsertThread(ctx, { - missionId, - threadId: requestedThreadId, - threadType, - title: deriveThreadTitle({ - target, - step: null, - lane: null, - fallback: args.fallbackTitle ?? undefined - }), - target - }); - } - - const target = sanitizeChatTarget(args.target); - if (!target || target.kind === "coordinator" || target.kind === "workers") { - return ensureMissionThread(ctx, missionId); - } - if (target.kind === "teammate") { - const identity = teammateThreadIdentity(target); - const fallbackId = `teammate:${missionId}:${identity ?? randomUUID()}`; - const existing = getThreadById(ctx, missionId, fallbackId); - if (existing) return existing; - return upsertThread(ctx, { - missionId, - threadId: fallbackId, - threadType: "teammate", - title: deriveThreadTitle({ - target, - step: null, - lane: null, - fallback: args.fallbackTitle ?? undefined - }), - target, - metadata: target.teamMemberId ? { teamMemberId: target.teamMemberId } : null - }); - } - const identity = workerThreadIdentity(target); - const fallbackId = `worker:${missionId}:${identity ?? randomUUID()}`; - const existing = getThreadById(ctx, missionId, fallbackId); - if (existing) return existing; - return upsertThread(ctx, { - missionId, - threadId: fallbackId, - threadType: "worker", - title: deriveThreadTitle({ - target, - step: null, - lane: null, - fallback: args.fallbackTitle ?? undefined - }), - target - }); -} - -function deriveTargetFromThread(thread: OrchestratorChatThread): OrchestratorChatTarget | null { - if (thread.threadType === "worker") { - return { - kind: "worker", - runId: thread.runId ?? null, - stepId: thread.stepId ?? null, - stepKey: thread.stepKey ?? null, - attemptId: thread.attemptId ?? null, - sessionId: thread.sessionId ?? null, - laneId: thread.laneId ?? null, - }; - } - if (thread.threadType === "teammate") { - const metadata = isRecord(thread.metadata) ? thread.metadata : {}; - return { - kind: "teammate", - runId: thread.runId ?? null, - teamMemberId: typeof metadata.teamMemberId === "string" ? metadata.teamMemberId : null, - sessionId: thread.sessionId ?? null, - }; - } - return { - kind: "coordinator", - runId: thread.runId ?? null, - sessionId: thread.sessionId ?? null, - laneId: thread.laneId ?? null, - }; -} - -function mergeThreadResolvedTarget( - thread: OrchestratorChatThread, - explicitTarget: OrchestratorChatTarget | null, -): OrchestratorChatTarget | null { - const threadTarget = deriveTargetFromThread(thread); - if (!threadTarget) return explicitTarget; - if (!explicitTarget) return threadTarget; - if (threadTarget.kind !== explicitTarget.kind) return explicitTarget; - // threadTarget.kind === explicitTarget.kind is guaranteed by the guard above, - // so we can safely narrow threadTarget inside each case branch. - switch (explicitTarget.kind) { - case "worker": { - const tt = threadTarget as Extract; - return { - kind: "worker", - runId: explicitTarget.runId ?? tt.runId ?? null, - stepId: explicitTarget.stepId ?? tt.stepId ?? null, - stepKey: explicitTarget.stepKey ?? tt.stepKey ?? null, - attemptId: explicitTarget.attemptId ?? tt.attemptId ?? null, - sessionId: explicitTarget.sessionId ?? tt.sessionId ?? null, - laneId: explicitTarget.laneId ?? tt.laneId ?? null, - }; - } - case "teammate": { - const tt = threadTarget as Extract; - return { - kind: "teammate", - runId: explicitTarget.runId ?? tt.runId ?? null, - teamMemberId: explicitTarget.teamMemberId ?? tt.teamMemberId ?? null, - sessionId: explicitTarget.sessionId ?? tt.sessionId ?? null, - }; - } - case "workers": - case "agent": - return explicitTarget; - case "coordinator": { - const tt = threadTarget as Extract; - return { - kind: "coordinator", - runId: explicitTarget.runId ?? tt.runId ?? null, - sessionId: explicitTarget.sessionId ?? tt.sessionId ?? null, - laneId: explicitTarget.laneId ?? tt.laneId ?? null, - }; - } - default: - return explicitTarget; - } -} - -// ── Chat Message Row Parsing ───────────────────────────────────── - -export type ChatMessageRow = { - id: string; - mission_id: string; - role: string; - content: string; - timestamp: string; - step_key: string | null; - thread_id: string | null; - target_json: string | null; - visibility: string | null; - delivery_state: string | null; - source_session_id: string | null; - attempt_id: string | null; - lane_id: string | null; - run_id: string | null; - metadata_json: string | null; -}; - -export function parseChatMessageRow(row: ChatMessageRow): OrchestratorChatMessage | null { - const role = - row.role === "user" || row.role === "worker" || row.role === "orchestrator" || row.role === "agent" - ? row.role - : null; - if (!role) return null; - return { - id: row.id, - missionId: row.mission_id, - role, - content: row.content, - timestamp: row.timestamp, - stepKey: row.step_key ?? null, - threadId: row.thread_id ?? null, - target: parseChatTarget(parseJsonRecord(row.target_json)), - visibility: normalizeChatVisibility(row.visibility), - deliveryState: normalizeChatDeliveryState(row.delivery_state), - sourceSessionId: row.source_session_id ?? null, - attemptId: row.attempt_id ?? null, - laneId: row.lane_id ?? null, - runId: row.run_id ?? null, - metadata: parseJsonRecord(row.metadata_json) - }; -} - -export function getChatMessageById(ctx: OrchestratorContext, messageId: string): OrchestratorChatMessage | null { - const row = ctx.db.get( - ` - select - id, - mission_id, - role, - content, - timestamp, - step_key, - thread_id, - target_json, - visibility, - delivery_state, - source_session_id, - attempt_id, - lane_id, - run_id, - metadata_json - from orchestrator_chat_messages - where id = ? - limit 1 - `, - [messageId] - ); - return row ? parseChatMessageRow(row) : null; -} - -export function loadChatMessagesFromDb( - ctx: OrchestratorContext, - args: { - missionId: string; - threadId?: string | null; - limit?: number; - before?: string | null; - } -): OrchestratorChatMessage[] { - const limit = clampLimit(args.limit, MAX_PERSISTED_CHAT_MESSAGES, 500); - const rows = ctx.db.all( - ` - select - id, - mission_id, - role, - content, - timestamp, - step_key, - thread_id, - target_json, - visibility, - delivery_state, - source_session_id, - attempt_id, - lane_id, - run_id, - metadata_json - from orchestrator_chat_messages - where mission_id = ? - and (? is null or thread_id = ?) - and (? is null or timestamp < ?) - order by timestamp desc - limit ? - `, - [ - args.missionId, - args.threadId ?? null, - args.threadId ?? null, - args.before ?? null, - args.before ?? null, - limit - ] - ); - if (!rows.length) return []; - return rows - .map((row) => parseChatMessageRow(row)) - .filter((entry): entry is OrchestratorChatMessage => !!entry) - .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); -} - -/** - * Summarize the latest run for a mission as a concise text string. - */ -export function summarizeRunForChat(ctx: OrchestratorContext, missionId: string): string { - const runs = ctx.orchestratorService.listRuns({ missionId }); - if (!runs.length) return "No run has started yet."; - const byCreatedDesc = [...runs].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); - const activeRun = byCreatedDesc.find((entry) => entry.status === "active" || entry.status === "bootstrapping" || entry.status === "queued" || entry.status === "paused"); - const targetRun = activeRun ?? byCreatedDesc[0]; - if (!targetRun) return "No run has started yet."; - try { - const graph = ctx.orchestratorService.getRunGraph({ runId: targetRun.id, timelineLimit: 0 }); - const total = graph.steps.length; - const running = graph.steps.filter((step) => step.status === "running").length; - const done = graph.steps.filter((step) => - step.status === "succeeded" || step.status === "failed" || step.status === "skipped" || step.status === "canceled" - ).length; - const failed = graph.steps.filter((step) => step.status === "failed").length; - const blocked = graph.steps.filter((step) => step.status === "blocked").length; - const runMeta = isRecord(graph.run.metadata) ? graph.run.metadata : {}; - const autopilot = isRecord(runMeta.autopilot) ? runMeta.autopilot : null; - const parallelCap = - autopilot && Number.isFinite(Number(autopilot.parallelismCap)) - ? Math.max(1, Math.floor(Number(autopilot.parallelismCap))) - : null; - const runningSteps = graph.steps - .filter((step) => step.status === "running") - .slice(0, 3) - .map((step) => `${step.title || step.stepKey}${step.laneId ? ` @${step.laneId}` : ""}`); - const readySteps = graph.steps - .filter((step) => step.status === "ready") - .slice(0, 3) - .map((step) => `${step.title || step.stepKey}${step.laneId ? ` @${step.laneId}` : ""}`); - return [ - `Run ${targetRun.id.slice(0, 8)} is ${targetRun.status}. Progress ${done}/${total}. Running ${running}. Failed ${failed}. Blocked ${blocked}.`, - parallelCap ? `Parallelism cap: ${parallelCap}.` : null, - runningSteps.length ? `Active steps: ${runningSteps.join("; ")}.` : null, - readySteps.length ? `Ready queue: ${readySteps.join("; ")}.` : null - ] - .filter((line): line is string => Boolean(line)) - .join(" "); - } catch { - return `Latest run ${targetRun.id.slice(0, 8)} is ${targetRun.status}.`; - } -} - -// ── Chat Message Update Persistence ───────────────────────────── - -export function persistUpdatedChatMessage( - ctx: OrchestratorContext, - next: OrchestratorChatMessage -): OrchestratorChatMessage { - const normalized: OrchestratorChatMessage = { - ...next, - target: sanitizeChatTarget(next.target ?? null), - visibility: normalizeChatVisibility(next.visibility), - deliveryState: normalizeChatDeliveryState(next.deliveryState), - metadata: isRecord(next.metadata) ? next.metadata : null - }; - ctx.db.run( - ` - update orchestrator_chat_messages - set - content = ?, - timestamp = ?, - thread_id = ?, - step_key = ?, - target_json = ?, - visibility = ?, - delivery_state = ?, - source_session_id = ?, - attempt_id = ?, - lane_id = ?, - run_id = ?, - metadata_json = ? - where id = ? - `, - [ - normalized.content, - normalized.timestamp, - normalized.threadId ?? missionThreadId(normalized.missionId), - normalized.stepKey ?? null, - normalized.target ? JSON.stringify(normalized.target) : null, - normalizeChatVisibility(normalized.visibility), - normalizeChatDeliveryState(normalized.deliveryState), - normalized.sourceSessionId ?? null, - normalized.attemptId ?? null, - normalized.laneId ?? null, - normalized.runId ?? null, - normalized.metadata ? JSON.stringify(normalized.metadata) : null, - normalized.id - ] - ); - - const current = ctx.chatMessages.get(normalized.missionId); - if (current?.length) { - const nextMessages = current.map((entry) => (entry.id === normalized.id ? normalized : entry)); - ctx.chatMessages.set(normalized.missionId, nextMessages); - } - - try { - updateMissionMetadata(ctx, normalized.missionId, (metadata) => { - const existing = Array.isArray(metadata[ORCHESTRATOR_CHAT_METADATA_KEY]) - ? (metadata[ORCHESTRATOR_CHAT_METADATA_KEY] as unknown[]) - : []; - metadata[ORCHESTRATOR_CHAT_METADATA_KEY] = existing.map((entry) => { - const parsed = parseChatMessage(entry, normalized.missionId); - return parsed?.id === normalized.id ? normalized : entry; - }); - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.chat_metadata_message_update_failed", { - missionId: normalized.missionId, - messageId: normalized.id, - error: error instanceof Error ? error.message : String(error) - }); - } - - emitThreadEvent(ctx, { - type: "message_updated", - missionId: normalized.missionId, - threadId: normalized.threadId ?? missionThreadId(normalized.missionId), - messageId: normalized.id, - runId: normalized.runId ?? null, - reason: "message_update" - }); - emitThreadEvent(ctx, { - type: "thread_updated", - missionId: normalized.missionId, - threadId: normalized.threadId ?? missionThreadId(normalized.missionId), - runId: normalized.runId ?? null, - reason: "message_update" - }); - - return normalized; -} - -export function updateChatMessage( - ctx: OrchestratorContext, - messageId: string, - updater: (current: OrchestratorChatMessage) => OrchestratorChatMessage -): OrchestratorChatMessage | null { - const current = getChatMessageById(ctx, messageId); - if (!current) return null; - const next = updater(current); - if (next.id !== current.id || next.missionId !== current.missionId) { - throw new Error("Chat message identity cannot change during update."); - } - return persistUpdatedChatMessage(ctx, next); -} - -// ── appendChatMessage ──────────────────────────────────────────── - -export function appendChatMessageCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage -): OrchestratorChatMessage { - if (ctx.disposed.current) { - return { - ...message, - target: sanitizeChatTarget(message.target ?? null), - visibility: normalizeChatVisibility(message.visibility), - deliveryState: normalizeChatDeliveryState(message.deliveryState), - metadata: isRecord(message.metadata) ? message.metadata : null - }; - } - const thread = ensureThreadForTarget(ctx, { - missionId: message.missionId, - threadId: message.threadId ?? null, - target: message.target ?? null - }); - const normalized: OrchestratorChatMessage = { - ...message, - threadId: thread.id, - visibility: normalizeChatVisibility(message.visibility), - deliveryState: normalizeChatDeliveryState(message.deliveryState), - target: sanitizeChatTarget(message.target ?? null), - metadata: isRecord(message.metadata) ? message.metadata : null - }; - const existing = ctx.chatMessages.has(normalized.missionId) - ? ctx.chatMessages.get(normalized.missionId) ?? [] - : loadChatMessagesFromMetadata(ctx, normalized.missionId); - const next = [...existing, normalized].slice(-MAX_PERSISTED_CHAT_MESSAGES); - ctx.chatMessages.set(message.missionId, next); - try { - updateMissionMetadata(ctx, normalized.missionId, (metadata) => { - metadata[ORCHESTRATOR_CHAT_METADATA_KEY] = next; - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.chat_persist_failed", { - missionId: normalized.missionId, - error: error instanceof Error ? error.message : String(error) - }); - } - const missionIdentity = getMissionIdentity(ctx, normalized.missionId); - if (missionIdentity) { - const createdAt = nowIso(); - ctx.db.run( - ` - insert into orchestrator_chat_messages( - id, - project_id, - mission_id, - thread_id, - role, - content, - timestamp, - step_key, - target_json, - visibility, - delivery_state, - source_session_id, - attempt_id, - lane_id, - run_id, - metadata_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - normalized.id, - missionIdentity.projectId, - normalized.missionId, - normalized.threadId ?? missionThreadId(normalized.missionId), - normalized.role, - normalized.content, - normalized.timestamp, - normalized.stepKey ?? null, - normalized.target ? JSON.stringify(normalized.target) : null, - normalizeChatVisibility(normalized.visibility), - normalizeChatDeliveryState(normalized.deliveryState), - normalized.sourceSessionId ?? null, - normalized.attemptId ?? null, - normalized.laneId ?? null, - normalized.runId ?? null, - normalized.metadata ? JSON.stringify(normalized.metadata) : null, - createdAt - ] - ); - const unreadIncrement = shouldCountAsMissionAttentionMessage(normalized) ? 1 : 0; - ctx.db.run( - ` - update orchestrator_chat_threads - set updated_at = ?, - unread_count = case when ? > 0 then unread_count + ? else unread_count end - where id = ? - `, - [normalized.timestamp, unreadIncrement, unreadIncrement, normalized.threadId ?? missionThreadId(normalized.missionId)] - ); - } - emitThreadEvent(ctx, { - type: "message_appended", - missionId: normalized.missionId, - threadId: normalized.threadId ?? missionThreadId(normalized.missionId), - messageId: normalized.id, - runId: normalized.runId ?? null, - reason: "append_message", - metadata: { - role: normalized.role, - deliveryState: normalized.deliveryState ?? null - } - }); - emitThreadEvent(ctx, { - type: "thread_updated", - missionId: normalized.missionId, - threadId: normalized.threadId ?? missionThreadId(normalized.missionId), - runId: normalized.runId ?? null, - reason: "append_message" - }); - return normalized; -} - -// ── Chat Routing (Group 2) ─────────────────────────────────────── - -export type ChatRoutingDeps = { - routeMessageToWorker: (message: OrchestratorChatMessage) => void; - routeMessageToCoordinator: (message: OrchestratorChatMessage) => void; - replayQueuedWorkerMessages: (...args: any[]) => Promise; - sendWorkerMessageToSession: (sessionId: string, content: string) => Promise; - createContextCheckpoint: (...args: any[]) => void; - listWorkerDigests: (...args: any[]) => any[]; -}; - -function ensureActiveWorkerThreadsForMission(ctx: OrchestratorContext, missionId: string): void { - const rows = ctx.db.all<{ - run_id: string; - step_id: string; - step_key: string | null; - title: string | null; - lane_id: string | null; - attempt_id: string; - executor_session_id: string | null; - }>( - ` - select - a.run_id, - a.step_id, - s.step_key, - s.title, - s.lane_id, - a.id as attempt_id, - a.executor_session_id - from orchestrator_attempts a - join orchestrator_steps s on s.id = a.step_id - join orchestrator_runs r on r.id = a.run_id - where r.mission_id = ? - and s.status in ('running', 'blocked') - and a.status in ('running', 'blocked') - `, - [missionId] - ); - - for (const row of rows) { - const attemptId = row.attempt_id?.trim(); - if (!attemptId) continue; - ensureThreadForTarget(ctx, { - missionId, - threadId: `worker:${missionId}:${attemptId}`, - target: { - kind: "worker", - runId: row.run_id, - stepId: row.step_id, - stepKey: row.step_key ?? null, - attemptId, - sessionId: row.executor_session_id ?? null, - laneId: row.lane_id ?? null, - }, - fallbackTitle: `Worker: ${row.title?.trim() || row.step_key?.trim() || attemptId.slice(0, 8)}`, - }); - } -} - -export function listChatThreadsCtx( - ctx: OrchestratorContext, - threadArgs: ListOrchestratorChatThreadsArgs -): OrchestratorChatThread[] { - ensureMissionThread(ctx, threadArgs.missionId); - ensureActiveWorkerThreadsForMission(ctx, threadArgs.missionId); - const rows = ctx.db.all<{ - id: string; - mission_id: string; - thread_type: string; - title: string; - run_id: string | null; - step_id: string | null; - step_key: string | null; - attempt_id: string | null; - session_id: string | null; - lane_id: string | null; - status: string; - unread_count: number | null; - metadata_json: string | null; - created_at: string; - updated_at: string; - }>( - ` - select - id, - mission_id, - thread_type, - title, - run_id, - step_id, - step_key, - attempt_id, - coalesce( - session_id, - ( - select m.source_session_id - from orchestrator_chat_messages m - where m.mission_id = orchestrator_chat_threads.mission_id - and m.thread_id = orchestrator_chat_threads.id - and m.source_session_id is not null - and trim(m.source_session_id) != '' - and m.source_session_id not like 'coordinator:%' - order by m.timestamp desc - limit 1 - ) - ) as session_id, - lane_id, - status, - unread_count, - metadata_json, - created_at, - updated_at - from orchestrator_chat_threads - where mission_id = ? - and (? = 1 or status != 'closed') - order by updated_at desc, created_at desc - `, - [threadArgs.missionId, threadArgs.includeClosed === true ? 1 : 0] - ); - return rows.map((row) => parseThreadRow(row)); -} - -export function getThreadMessagesCtx( - ctx: OrchestratorContext, - threadArgs: GetOrchestratorThreadMessagesArgs -): OrchestratorChatMessage[] { - const thread = ensureThreadForTarget(ctx, { - missionId: threadArgs.missionId, - threadId: threadArgs.threadId - }); - if (!threadArgs.before) { - ctx.db.run( - ` - update orchestrator_chat_threads - set unread_count = 0 - where mission_id = ? - and id = ? - `, - [threadArgs.missionId, thread.id] - ); - if (thread.unreadCount > 0) { - emitThreadEvent(ctx, { - type: "thread_updated", - missionId: threadArgs.missionId, - threadId: thread.id, - runId: thread.runId ?? null, - reason: "thread_read" - }); - } - } - const messages = loadChatMessagesFromDb(ctx, { - missionId: threadArgs.missionId, - threadId: thread.id, - limit: threadArgs.limit ?? MAX_PERSISTED_CHAT_MESSAGES, - before: threadArgs.before ?? null - }); - if (messages.length > 0) return messages; - if (thread.threadType === "coordinator") { - return (ctx.chatMessages.get(threadArgs.missionId) ?? loadChatMessagesFromMetadata(ctx, threadArgs.missionId)) - .filter((entry) => (entry.threadId ?? missionThreadId(threadArgs.missionId)) === thread.id) - .slice(-clampLimit(threadArgs.limit, MAX_PERSISTED_CHAT_MESSAGES, 500)); - } - return []; -} - -export function sendWorkersBroadcastMessageCtx( - ctx: OrchestratorContext, - threadArgs: SendOrchestratorThreadMessageArgs, - target: Extract, - deps: ChatRoutingDeps -): OrchestratorChatMessage { - const missionThread = ensureMissionThread(ctx, threadArgs.missionId); - const attachments = normalizeThreadAttachments(threadArgs.attachments); - const metadata = buildStructuredUserMessageMetadata({ - content: threadArgs.content, - attachments, - metadata: threadArgs.metadata, - }); - const broadcastMessage = appendChatMessageCtx(ctx, { - id: randomUUID(), - missionId: threadArgs.missionId, - role: "user", - content: threadArgs.content, - timestamp: nowIso(), - threadId: missionThread.id, - target, - visibility: normalizeChatVisibility(threadArgs.visibilityMode, DEFAULT_CHAT_VISIBILITY), - deliveryState: "delivered", - sourceSessionId: null, - attemptId: null, - laneId: target.laneId ?? null, - runId: target.runId ?? null, - stepKey: null, - metadata - }); - - const candidates = listChatThreadsCtx(ctx, { - missionId: threadArgs.missionId, - includeClosed: target.includeClosed === true - }) - .filter((thread) => thread.threadType === "worker") - .filter((thread) => !target.runId || thread.runId === target.runId) - .filter((thread) => !target.laneId || thread.laneId === target.laneId) - .sort((a, b) => { - const left = Date.parse(a.createdAt); - const right = Date.parse(b.createdAt); - if (left !== right) return left - right; - return a.id.localeCompare(b.id); - }); - - if (!candidates.length) { - emitOrchestratorMessage( - ctx, - threadArgs.missionId, - "Worker broadcast queued no deliveries because no matching worker threads are currently available.", - null, - { - sourceMessageId: broadcastMessage.id, - target - }, - { appendChatMessage: (msg) => appendChatMessageCtx(ctx, msg) } - ); - return broadcastMessage; - } - - for (let index = 0; index < candidates.length; index += 1) { - const thread = candidates[index]!; - const workerTarget: OrchestratorChatTarget = { - kind: "worker", - runId: thread.runId ?? target.runId ?? null, - stepId: thread.stepId ?? null, - stepKey: thread.stepKey ?? null, - attemptId: thread.attemptId ?? null, - sessionId: thread.sessionId ?? null, - laneId: thread.laneId ?? target.laneId ?? null - }; - const nextMetadata: Record = { - ...(metadata ?? {}), - workerBroadcast: { - sourceMessageId: broadcastMessage.id, - fanoutIndex: index + 1, - fanoutTotal: candidates.length - } - }; - sendThreadMessageCtx(ctx, { - missionId: threadArgs.missionId, - threadId: thread.id, - content: threadArgs.content, - attachments, - target: workerTarget, - visibilityMode: normalizeChatVisibility(threadArgs.visibilityMode, DEFAULT_WORKER_CHAT_VISIBILITY), - metadata: nextMetadata - }, deps); - } - - emitOrchestratorMessage( - ctx, - threadArgs.missionId, - `Broadcast worker guidance to ${candidates.length} worker thread${candidates.length === 1 ? "" : "s"}.`, - null, - { - sourceMessageId: broadcastMessage.id, - target, - deliveredThreads: candidates.map((thread) => thread.id) - }, - { appendChatMessage: (msg) => appendChatMessageCtx(ctx, msg) } - ); - return broadcastMessage; -} - -export function sendThreadMessageCtx( - ctx: OrchestratorContext, - threadArgs: SendOrchestratorThreadMessageArgs, - deps: ChatRoutingDeps -): OrchestratorChatMessage { - const explicitTarget = sanitizeChatTarget(threadArgs.target); - if (explicitTarget?.kind === "workers") { - return sendWorkersBroadcastMessageCtx(ctx, threadArgs, explicitTarget, deps); - } - const thread = ensureThreadForTarget(ctx, { - missionId: threadArgs.missionId, - threadId: threadArgs.threadId ?? null, - target: explicitTarget - }); - const target = mergeThreadResolvedTarget(thread, explicitTarget); - const isWorkerTarget = target?.kind === "worker"; - const isTeammateTarget = target?.kind === "teammate"; - const attachments = normalizeThreadAttachments(threadArgs.attachments); - const deliveryContent = `${threadArgs.content}${buildAttachmentContextSuffix(attachments)}`; - const visibilityFallback = isWorkerTarget ? DEFAULT_WORKER_CHAT_VISIBILITY : DEFAULT_CHAT_VISIBILITY; - const visibility = normalizeChatVisibility(threadArgs.visibilityMode, visibilityFallback); - const deliveryState: OrchestratorChatDeliveryState = - isWorkerTarget || isTeammateTarget ? "queued" : DEFAULT_CHAT_DELIVERY; - const persistedContent = isWorkerTarget && attachments.length > 0 ? deliveryContent : threadArgs.content; - const messageMetadata = buildStructuredUserMessageMetadata({ - content: threadArgs.content, - attachments, - metadata: threadArgs.metadata, - }); - const msg = appendChatMessageCtx(ctx, { - id: randomUUID(), - missionId: threadArgs.missionId, - role: "user", - content: persistedContent, - timestamp: nowIso(), - threadId: thread.id, - target: target ?? (thread.threadType === "coordinator" ? { kind: "coordinator", runId: thread.runId ?? null } : null), - visibility, - deliveryState, - sourceSessionId: - isWorkerTarget - ? target.sessionId ?? null - : isTeammateTarget - ? target.sessionId ?? null - : null, - attemptId: isWorkerTarget ? target.attemptId ?? null : null, - laneId: isWorkerTarget ? target.laneId ?? null : null, - runId: target?.runId ?? thread.runId ?? null, - stepKey: isWorkerTarget ? target.stepKey ?? null : null, - metadata: messageMetadata - }); - if ((ctx.chatMessages.get(threadArgs.missionId)?.length ?? 0) >= CONTEXT_CHECKPOINT_CHAT_THRESHOLD) { - const total = ctx.chatMessages.get(threadArgs.missionId)?.length ?? 0; - if (total % CONTEXT_CHECKPOINT_CHAT_THRESHOLD === 0) { - deps.createContextCheckpoint({ - missionId: threadArgs.missionId, - runId: msg.runId ?? null, - trigger: "step_threshold", - summary: `Compressed mission chat context at ${total} messages.`, - source: { - digestCount: deps.listWorkerDigests({ missionId: threadArgs.missionId, limit: 1_000 }).length, - chatMessageCount: total, - compressedMessageCount: Math.floor(total / 2) - } - }); - } - } - if (msg.target?.kind === "worker") { - deps.routeMessageToWorker(attachments.length > 0 ? { ...msg, content: threadArgs.content } : msg); - void deps.replayQueuedWorkerMessages({ - reason: "send_thread_message", - missionId: threadArgs.missionId, - threadId: msg.threadId ?? null, - sessionId: msg.target.sessionId ?? null - }).catch((error: unknown) => { - ctx.logger.debug("ai_orchestrator.worker_delivery_replay_enqueue_failed", { - missionId: threadArgs.missionId, - threadId: msg.threadId ?? null, - error: error instanceof Error ? error.message : String(error) - }); - }); - } else if (msg.target?.kind === "teammate") { - const teammateSessionId = toOptionalString(msg.target.sessionId); - if (!teammateSessionId) { - updateChatMessage(ctx, msg.id, (current) => ({ ...current, deliveryState: "failed" })); - return msg; - } - void Promise.resolve() - .then(async () => { - await deps.sendWorkerMessageToSession(teammateSessionId, attachments.length > 0 ? deliveryContent : threadArgs.content); - updateChatMessage(ctx, msg.id, (current) => ({ ...current, deliveryState: "delivered" })); - }) - .catch((error: unknown) => { - updateChatMessage(ctx, msg.id, (current) => ({ ...current, deliveryState: "failed" })); - ctx.logger.debug("ai_orchestrator.teammate_delivery_failed", { - missionId: threadArgs.missionId, - threadId: msg.threadId ?? null, - sessionId: teammateSessionId, - error: error instanceof Error ? error.message : String(error) - }); - }); - } else { - const coordinatorContent = - attachments.length > 0 && !shouldPreserveCoordinatorPromptForRouting(threadArgs.content) - ? deliveryContent - : threadArgs.content; - deps.routeMessageToCoordinator( - coordinatorContent === msg.content - ? msg - : { ...msg, content: coordinatorContent } - ); - } - return msg; -} - -export function sendChatCtx( - ctx: OrchestratorContext, - chatArgs: SendOrchestratorChatArgs, - deps: ChatRoutingDeps -): OrchestratorChatMessage { - const resolvedTarget = - chatArgs.target - ?? (chatArgs.threadId ? undefined : { kind: "coordinator", runId: null } satisfies OrchestratorChatTarget); - return sendThreadMessageCtx(ctx, { - missionId: chatArgs.missionId, - content: chatArgs.content, - threadId: chatArgs.threadId ?? missionThreadId(chatArgs.missionId), - ...(resolvedTarget ? { target: resolvedTarget } : {}), - visibilityMode: chatArgs.visibilityMode ?? DEFAULT_CHAT_VISIBILITY, - metadata: chatArgs.metadata ?? null - }, deps); -} - -export function getChatCtx( - ctx: OrchestratorContext, - chatArgs: GetOrchestratorChatArgs -): OrchestratorChatMessage[] { - return getThreadMessagesCtx(ctx, { - missionId: chatArgs.missionId, - threadId: missionThreadId(chatArgs.missionId), - limit: MAX_PERSISTED_CHAT_MESSAGES - }); -} - -export function sendAgentMessageCtx( - ctx: OrchestratorContext, - args: SendAgentMessageArgs -): OrchestratorChatMessage { - const { missionId, fromAttemptId, toAttemptId, content, metadata } = args; - - // Find or create a thread for the source agent - const sourceThread = ensureThreadForTarget(ctx, { - missionId, - threadId: null, - target: { kind: "worker", attemptId: fromAttemptId } as OrchestratorChatTarget - }); - - // Record message in source agent's thread - const agentMsg = appendChatMessageCtx(ctx, { - id: randomUUID(), - missionId, - role: "agent", - content, - timestamp: nowIso(), - threadId: sourceThread.id, - target: { kind: "agent", sourceAttemptId: fromAttemptId, targetAttemptId: toAttemptId }, - visibility: "full" as OrchestratorChatVisibilityMode, - deliveryState: "delivered" as OrchestratorChatDeliveryState, - sourceSessionId: null, - attemptId: fromAttemptId, - laneId: null, - runId: sourceThread.runId ?? null, - stepKey: null, - metadata: metadata ?? null - }); - - // Also deliver to target agent's thread - const targetThread = ensureThreadForTarget(ctx, { - missionId, - threadId: null, - target: { kind: "worker", attemptId: toAttemptId } as OrchestratorChatTarget - }); - - appendChatMessageCtx(ctx, { - id: randomUUID(), - missionId, - role: "agent", - content, - timestamp: nowIso(), - threadId: targetThread.id, - target: { kind: "agent", sourceAttemptId: fromAttemptId, targetAttemptId: toAttemptId }, - visibility: "full" as OrchestratorChatVisibilityMode, - deliveryState: "delivered" as OrchestratorChatDeliveryState, - sourceSessionId: null, - attemptId: fromAttemptId, - laneId: null, - runId: targetThread.runId ?? null, - stepKey: null, - metadata: { ...(metadata ?? {}), interAgentDelivery: true } - }); - - if (sourceThread.runId ?? targetThread.runId) { - try { - ctx.orchestratorService.appendRuntimeEvent({ - runId: sourceThread.runId ?? targetThread.runId ?? "", - stepId: sourceThread.stepId ?? targetThread.stepId ?? null, - attemptId: fromAttemptId, - eventType: "worker_message", - eventKey: `worker_message:${fromAttemptId}:${toAttemptId}:${agentMsg.id}`, - occurredAt: agentMsg.timestamp, - payload: { - audience: "mission_feed", - sourceAttemptId: fromAttemptId, - targetAttemptId: toAttemptId, - sourceThreadId: sourceThread.id, - targetThreadId: targetThread.id, - sourceLabel: sourceThread.title, - targetLabel: targetThread.title, - summary: `${sourceThread.title} sent a message to ${targetThread.title}.`, - }, - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.inter_agent_runtime_event_failed", { - missionId, - fromAttemptId, - toAttemptId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Emit thread event so UI updates - emitThreadEvent(ctx, { - type: "message_appended", - missionId, - threadId: sourceThread.id, - messageId: agentMsg.id, - reason: "agent_message" - }); - emitThreadEvent(ctx, { - type: "message_appended", - missionId, - threadId: targetThread.id, - messageId: agentMsg.id, - reason: "agent_message_delivery" - }); - - return agentMsg; -} - -// ── Inter-agent messaging: route message to mentioned agents ── -export function routeMessageCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage, - mentions: string[], - deps: { deliverMessageToAgent: (...args: any[]) => Promise } -): void { - if (!message.missionId) return; - const missionId = message.missionId; - - // Resolve active run to find agents - const runs = ctx.orchestratorService.listRuns({ missionId }); - const activeRun = runs.find( - (r) => r.status === "active" || r.status === "bootstrapping" || r.status === "queued" || r.status === "paused" - ); - if (!activeRun) return; - - const isBroadcast = mentions.includes("all"); - - if (isBroadcast) { - // Deliver to all active workers - for (const [, ws] of ctx.workerStates.entries()) { - if (ws.runId !== activeRun.id || ws.state !== "working" || !ws.sessionId) continue; - void deps.deliverMessageToAgent({ - missionId, - targetAttemptId: ws.attemptId, - content: message.content, - priority: "normal" - }).catch((error: unknown) => { - ctx.logger.debug("ai_orchestrator.route_message_delivery_failed", { - missionId, - targetAttemptId: ws.attemptId, - error: error instanceof Error ? error.message : String(error) - }); - }); - } - return; - } - - // Deliver to each specifically mentioned agent (by step key) - const graph = ctx.orchestratorService.getRunGraph({ runId: activeRun.id, timelineLimit: 0 }); - for (const mention of mentions) { - if (mention === "orchestrator") { - // Messages to orchestrator are already in the chat system - continue; - } - const targetStep = graph.steps.find((s) => s.stepKey === mention); - if (!targetStep) continue; - const targetWorker = [...ctx.workerStates.entries()].find( - ([, ws]) => ws.stepId === targetStep.id && ws.runId === activeRun.id && ws.state === "working" - ); - if (targetWorker) { - void deps.deliverMessageToAgent({ - missionId, - targetAttemptId: targetWorker[1].attemptId, - content: message.content, - priority: "normal" - }).catch((error: unknown) => { - ctx.logger.debug("ai_orchestrator.route_message_delivery_failed", { - missionId, - targetAttemptId: targetWorker[1].attemptId, - error: error instanceof Error ? error.message : String(error) - }); - }); - } - } -} - -function queueRecoverableAgentDeliveryCtx( - ctx: OrchestratorContext, - args: { - missionId: string; - targetAttemptId: string; - content: string; - fromAttemptId?: string | null; - } -): void { - const targetThread = ensureThreadForTarget(ctx, { - missionId: args.missionId, - threadId: null, - target: { kind: "worker", attemptId: args.targetAttemptId } as OrchestratorChatTarget - }); - const queued = appendChatMessageCtx(ctx, { - id: randomUUID(), - missionId: args.missionId, - role: "user", - content: args.content, - timestamp: nowIso(), - threadId: targetThread.id, - target: { kind: "worker", attemptId: args.targetAttemptId } as OrchestratorChatTarget, - visibility: "full" as OrchestratorChatVisibilityMode, - deliveryState: "queued" as OrchestratorChatDeliveryState, - sourceSessionId: null, - attemptId: args.targetAttemptId, - laneId: null, - runId: targetThread.runId ?? null, - stepKey: null, - metadata: { - interAgentDelivery: true, - queuedFallback: true, - fromAttemptId: args.fromAttemptId ?? null, - } - }); - emitThreadEvent(ctx, { - type: "message_appended", - missionId: args.missionId, - threadId: queued.threadId ?? targetThread.id, - messageId: queued.id, - reason: "agent_message_delivery_queued" - }); -} - -// ── Inter-agent messaging: deliver message to a running agent ── -export async function deliverMessageToAgentCtx( - ctx: OrchestratorContext, - args: { - missionId: string; - targetAttemptId: string; - content: string; - priority?: "normal" | "urgent"; - fromAttemptId?: string | null; - }, - deps: { sendWorkerMessageToSession: (sessionId: string, content: string) => Promise } -): Promise<{ delivered: boolean; method: string }> { - const ws = ctx.workerStates.get(args.targetAttemptId); - if (!ws) { - queueRecoverableAgentDeliveryCtx(ctx, { - missionId: args.missionId, - targetAttemptId: args.targetAttemptId, - content: args.content, - fromAttemptId: args.fromAttemptId ?? null, - }); - return { delivered: false, method: "queued" }; - } - - const priority = args.priority ?? "normal"; - const prefix = args.fromAttemptId ? `[Message from ${args.fromAttemptId}] ` : "[Team message] "; - const formattedContent = `${prefix}${args.content}`; - - // For agents with an active session (both CLI-wrapped and SDK), use the existing delivery mechanism - if (ws.sessionId && ctx.agentChatService) { - try { - if (priority === "urgent") { - // Use steer for urgent messages — injects immediately even if agent is busy - try { - await ctx.agentChatService.steer({ sessionId: ws.sessionId, text: formattedContent }); - return { delivered: true, method: "steer" }; - } catch { - // Fall through to sendMessage - } - } - const method = await deps.sendWorkerMessageToSession(ws.sessionId, formattedContent); - return { delivered: true, method }; - } catch (error) { - ctx.logger.debug("ai_orchestrator.deliver_message_to_agent_failed", { - missionId: args.missionId, - targetAttemptId: args.targetAttemptId, - sessionId: ws.sessionId, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - queueRecoverableAgentDeliveryCtx(ctx, { - missionId: args.missionId, - targetAttemptId: args.targetAttemptId, - content: formattedContent, - fromAttemptId: args.fromAttemptId ?? null, - }); - return { delivered: false, method: "queued" }; -} - -// ── Global chat: all messages for a mission across all threads ── -export function getGlobalChatCtx( - ctx: OrchestratorContext, - args: GetGlobalChatArgs -): OrchestratorChatMessage[] { - const limit = Math.min(Math.max(args.limit ?? 200, 1), 1000); - return loadChatMessagesFromDb(ctx, { - missionId: args.missionId, - threadId: null, // null = all threads - limit, - before: args.since ? null : null // since is used as a floor, not a ceiling - }).filter((msg) => { - if (!args.since) return true; - return Date.parse(msg.timestamp) >= Date.parse(args.since); - }); -} - -// ── Active agents: list for @mention autocomplete ── -export function getActiveAgentsCtx( - ctx: OrchestratorContext, - args: GetActiveAgentsArgs -): ActiveAgentInfo[] { - const result: ActiveAgentInfo[] = []; - // Find active run for the mission - const runs = ctx.orchestratorService.listRuns({ missionId: args.missionId }); - const activeRun = runs.find( - (r) => r.status === "active" || r.status === "bootstrapping" || r.status === "queued" || r.status === "paused" - ); - if (!activeRun) return result; - - let graph: OrchestratorRunGraph | null = null; - try { - graph = ctx.orchestratorService.getRunGraph({ runId: activeRun.id, timelineLimit: 0 }); - } catch { - return result; - } - - for (const [, ws] of ctx.workerStates.entries()) { - if (ws.runId !== activeRun.id) continue; - if (ws.state !== "working" && ws.state !== "waiting_input" && ws.state !== "idle") continue; - const step = graph.steps.find((s) => s.id === ws.stepId); - result.push({ - attemptId: ws.attemptId, - stepId: ws.stepId, - stepKey: step?.stepKey ?? null, - runId: ws.runId, - sessionId: ws.sessionId, - state: ws.state, - executorKind: ws.executorKind - }); - } - return result; -} - -// ── Wire @mention parsing into sendAgentMessage flow ── -export function sendAgentMessageWithMentionsCtx( - ctx: OrchestratorContext, - agentMsgArgs: SendAgentMessageArgs, - deps: { deliverMessageToAgent: (...args: any[]) => Promise } -): OrchestratorChatMessage { - const msg = sendAgentMessageCtx(ctx, agentMsgArgs); - const { mentions } = parseMentions(agentMsgArgs.content); - if (mentions.length > 0) { - routeMessageCtx(ctx, msg, mentions, deps); - } - return msg; -} - -// ── Reconciliation (Group 4) ───────────────────────────────────── - -export type ReconciliationDeps = { - resolveWorkerDeliveryContext: (message: OrchestratorChatMessage) => any | null; - persistThreadWorkerLinks: (context: any) => void; - replayQueuedWorkerMessages: (...args: any[]) => Promise; -}; - -export function reconcileMissingThreadRowsCtx( - ctx: OrchestratorContext, - missionId: string -): number { - const orphans = ctx.db.all<{ - thread_id: string; - target_json: string | null; - }>( - ` - select distinct - m.thread_id as thread_id, - m.target_json as target_json - from orchestrator_chat_messages m - left join orchestrator_chat_threads t on t.id = m.thread_id - where m.mission_id = ? - and t.id is null - `, - [missionId] - ); - let repaired = 0; - for (const orphan of orphans) { - const threadId = toOptionalString(orphan.thread_id); - if (!threadId) continue; - ensureThreadForTarget(ctx, { - missionId, - threadId, - target: parseChatTarget(parseJsonRecord(orphan.target_json)) - }); - repaired += 1; - } - return repaired; -} - -export function reconcileWorkerThreadLinksCtx( - ctx: OrchestratorContext, - missionId: string, - deps: ReconciliationDeps -): number { - const rows = ctx.db.all<{ - id: string; - run_id: string | null; - step_id: string | null; - step_key: string | null; - attempt_id: string | null; - session_id: string | null; - lane_id: string | null; - }>( - ` - select - id, - run_id, - step_id, - step_key, - attempt_id, - session_id, - lane_id - from orchestrator_chat_threads - where mission_id = ? - and thread_type = 'worker' - `, - [missionId] - ); - let repaired = 0; - for (const row of rows) { - const latestMessage = loadChatMessagesFromDb(ctx, { - missionId, - threadId: row.id, - limit: 1 - })[0] ?? null; - const synthetic: OrchestratorChatMessage = latestMessage ?? { - id: `reconcile:${row.id}`, - missionId, - role: "orchestrator", - content: "Thread link reconciliation", - timestamp: nowIso(), - threadId: row.id, - target: { - kind: "worker", - runId: row.run_id ?? null, - stepId: row.step_id ?? null, - stepKey: row.step_key ?? null, - attemptId: row.attempt_id ?? null, - sessionId: row.session_id ?? null, - laneId: row.lane_id ?? null - }, - visibility: "metadata_only", - deliveryState: "queued", - sourceSessionId: row.session_id ?? null, - attemptId: row.attempt_id ?? null, - laneId: row.lane_id ?? null, - runId: row.run_id ?? null, - stepKey: row.step_key ?? null, - metadata: null - }; - const context = deps.resolveWorkerDeliveryContext(synthetic); - if (!context) continue; - deps.persistThreadWorkerLinks(context); - repaired += 1; - } - return repaired; -} - -export function reconcileUnreadSanityCtx( - ctx: OrchestratorContext, - missionId: string -): number { - const threads = ctx.db.all<{ id: string; unread_count: number | null }>( - ` - select id, unread_count - from orchestrator_chat_threads - where mission_id = ? - `, - [missionId] - ); - let updated = 0; - for (const thread of threads) { - const totalNonUser = ctx.db.get<{ count: number }>( - ` - select count(1) as count - from orchestrator_chat_messages - where thread_id = ? - and role != 'user' - `, - [thread.id] - ); - const total = Math.max(0, Math.floor(Number(totalNonUser?.count) || 0)); - const current = Number.isFinite(Number(thread.unread_count)) - ? Math.floor(Number(thread.unread_count)) - : total; - const normalized = Math.min(total, Math.max(0, current)); - if (normalized === current) continue; - ctx.db.run( - ` - update orchestrator_chat_threads - set unread_count = ? - where mission_id = ? - and id = ? - `, - [normalized, missionId, thread.id] - ); - updated += 1; - } - return updated; -} - -export async function reconcileThreadedMessagingStateCtx( - ctx: OrchestratorContext, - deps: ReconciliationDeps -): Promise { - if (ctx.disposed.current) return; - const missions = ctx.db.all<{ id: string }>( - ` - select id - from missions - order by created_at asc - ` - ); - let missingThreadsRepaired = 0; - let linksRepaired = 0; - let unreadNormalized = 0; - - for (const mission of missions) { - if (ctx.disposed.current) return; - const missionId = toOptionalString(mission.id); - if (!missionId) continue; - ensureMissionThread(ctx, missionId); - missingThreadsRepaired += reconcileMissingThreadRowsCtx(ctx, missionId); - linksRepaired += reconcileWorkerThreadLinksCtx(ctx, missionId, deps); - unreadNormalized += reconcileUnreadSanityCtx(ctx, missionId); - } - - if (missingThreadsRepaired > 0 || linksRepaired > 0 || unreadNormalized > 0) { - ctx.logger.info("ai_orchestrator.chat_reconciliation_complete", { - missions: missions.length, - missingThreadsRepaired, - linksRepaired, - unreadNormalized - }); - } - - await deps.replayQueuedWorkerMessages({ - reason: "startup" - }); -} diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts deleted file mode 100644 index 7d51dd8ca..000000000 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts +++ /dev/null @@ -1,2589 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { CoordinatorAgent } from "./coordinatorAgent"; -import { startOpenCodeSession } from "../opencode/openCodeRuntime"; - -const mockState = vi.hoisted(() => ({ - eventBatches: [] as Array, - promptAsync: vi.fn(async () => undefined), - close: vi.fn(), -})); - -vi.mock("../opencode/openCodeRuntime", () => ({ - buildOpenCodePromptParts: vi.fn(({ prompt }: { prompt: string }) => [{ type: "text", text: prompt }]), - mapPermissionModeToOpenCodeAgent: vi.fn(() => "ade-plan"), - refreshOpenCodeSessionToolSelection: vi.fn(async () => null), - resolveOpenCodeModelSelection: vi.fn((descriptor: Record) => ({ - providerID: String(descriptor.family ?? "anthropic"), - modelID: String(descriptor.providerModelId ?? descriptor.id ?? "model"), - })), - startOpenCodeSession: vi.fn(async (args: { directory: string }) => ({ - client: { - session: { - promptAsync: mockState.promptAsync, - }, - }, - server: { url: "http://127.0.0.1:4096", close: mockState.close }, - sessionId: "session-1", - directory: args.directory, - close: mockState.close, - touch: vi.fn(), - setBusy: vi.fn(), - setEvictionHandler: vi.fn(), - })), - openCodeEventStream: vi.fn(async () => ({ - async *[Symbol.asyncIterator]() { - const batch = mockState.eventBatches.shift() ?? []; - for (const event of batch) { - yield event; - } - }, - })), -})); - -function createPlanningPhases() { - return [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - askQuestions: { enabled: true, maxQuestions: 3 }, - validationGate: { tier: "none", required: false }, - orderingConstraints: { mustBeFirst: true }, - requiresApproval: false, - isBuiltIn: true, - isCustom: false, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - { - id: "phase-development", - phaseKey: "development", - name: "Development", - position: 1, - instructions: "Implement.", - model: { modelId: "openai/gpt-5.3-codex" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "none", required: false }, - orderingConstraints: {}, - requiresApproval: false, - isBuiltIn: true, - isCustom: false, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ] as any; -} - -function createCustomPlanningPhases() { - return [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Plan first.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "none", required: false }, - orderingConstraints: { mustBeFirst: true }, - requiresApproval: false, - isBuiltIn: true, - isCustom: false, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - { - id: "phase-implementation-math", - phaseKey: "implementation_math", - name: "Implementation math", - position: 1, - instructions: "Implement math work.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["planning"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ] as any; -} - -function createTestCoordinatorAgent(args?: { - onCoordinatorEvent?: (event: any) => void; - onPlanningStartupFailure?: (failure: any) => void; - onCoordinatorRuntimeFailure?: (failure: any) => void; - onCoordinatorChatSessionStarted?: (args: any) => void; - onCoordinatorChatRuntimeTurnStarted?: (args: any) => void; - onCoordinatorChatRuntimeTurnFinished?: (args: any) => void; - agentChatService?: any; - coordinatorReasoningEffort?: string | null; - missionLaneId?: string; - phases?: any[]; - runStatus?: string; - modelId?: string; - missionPrompt?: string | null; - missionServicePrompt?: string | null; - runMetadataJson?: Record | null; -}) { - const graph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [] as any[], - attempts: [] as any[], - }; - - const agent = new CoordinatorAgent({ - orchestratorService: { - getRunGraph: vi.fn(() => graph), - addSteps: vi.fn(({ steps }: { steps: any[] }) => - steps.map((step, index) => { - const created = { - id: `step-created-${graph.steps.length + index + 1}`, - runId: "run-1", - missionStepId: null, - stepKey: step.stepKey, - stepIndex: step.stepIndex ?? graph.steps.length + index, - title: step.title ?? step.stepKey, - laneId: step.laneId ?? null, - status: "pending", - executorKind: step.executorKind ?? null, - joinPolicy: step.joinPolicy ?? "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: step.metadata ?? {}, - }; - graph.steps.push(created); - return created; - }), - ), - startReadyAutopilotAttempts: vi.fn(async () => 0), - appendTimelineEvent: vi.fn(), - emitRuntimeUpdate: vi.fn(), - skipStep: vi.fn(({ stepId }: { stepId: string }) => { - const step = graph.steps.find((entry) => entry.id === stepId); - if (step) step.status = "skipped"; - }), - completeAttempt: vi.fn(async () => undefined), - } as any, - runId: "run-1", - missionId: "mission-1", - missionGoal: "Test mission", - modelId: args?.modelId ?? "anthropic/claude-sonnet-4-6", - logger: { - info: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - } as any, - db: { - get: vi.fn((sql: string) => { - if (sql.includes("SELECT status FROM orchestrator_runs")) { - return { status: args?.runStatus ?? "active" }; - } - if (sql.toLowerCase().includes("from missions") && sql.toLowerCase().includes("prompt")) { - return { prompt: args?.missionPrompt ?? null }; - } - if (sql.toLowerCase().includes("from orchestrator_runs") && sql.toLowerCase().includes("metadata_json")) { - return { - metadata_json: args?.runMetadataJson === null - ? null - : JSON.stringify(args?.runMetadataJson ?? {}), - }; - } - return null; - }), - all: vi.fn(() => []), - run: vi.fn(), - } as any, - projectId: "project-1", - projectRoot: "/tmp/ade-project", - workspaceRoot: "/tmp/ade-worktree", - missionService: { - get: vi.fn(() => ({ prompt: args?.missionServicePrompt ?? undefined, interventions: [] })), - } as any, - aiIntegrationService: { - executeTask: vi.fn(async () => ({ - text: "Compacted summary", - structuredOutput: null, - provider: "claude", - model: args?.modelId ?? "anthropic/claude-sonnet-4-6", - sessionId: null, - inputTokens: null, - outputTokens: null, - durationMs: 1, - })), - } as any, - agentChatService: args?.agentChatService ?? null, - coordinatorReasoningEffort: args?.coordinatorReasoningEffort ?? null, - projectConfigService: { - get: vi.fn(() => ({ effective: { ai: {} } })), - } as any, - onDagMutation: vi.fn(), - onCoordinatorEvent: args?.onCoordinatorEvent, - onCoordinatorChatSessionStarted: args?.onCoordinatorChatSessionStarted, - onCoordinatorChatRuntimeTurnStarted: args?.onCoordinatorChatRuntimeTurnStarted, - onCoordinatorChatRuntimeTurnFinished: args?.onCoordinatorChatRuntimeTurnFinished, - onPlanningStartupFailure: args?.onPlanningStartupFailure, - onCoordinatorRuntimeFailure: args?.onCoordinatorRuntimeFailure, - missionLaneId: args?.missionLaneId, - phases: args?.phases, - }); - (agent as any).__graph = graph; - return agent; -} - -beforeEach(() => { - mockState.eventBatches.length = 0; - mockState.promptAsync.mockReset(); - mockState.close.mockReset(); -}); - -describe("CoordinatorAgent", () => { - it("respects phase decks that intentionally skip planning", () => { - const developmentPhase = { - id: "phase-development", - phaseKey: "development", - name: "Development", - position: 0, - instructions: "Implement directly.", - model: { modelId: "openai/gpt-5.5" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "none", required: false }, - orderingConstraints: {}, - requiresApproval: false, - isBuiltIn: true, - isCustom: false, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }; - const agent = createTestCoordinatorAgent({ phases: [developmentPhase] }) as any; - - try { - expect(agent.deps.phases.map((phase: any) => phase.phaseKey)).toEqual(["development"]); - expect(agent.systemPrompt).toContain("1. DEVELOPMENT (model: openai/gpt-5.5)"); - expect(agent.systemPrompt).not.toContain("PLANNING (model:"); - expect(agent.__graph.steps.some((step: any) => step.stepKey === "planner-launch-tracker")).toBe(false); - } finally { - agent.shutdown(); - } - }); - - it("labels runtime-created testing phase workers as testing even when dedicated validation is required", () => { - const agent = createTestCoordinatorAgent() as any; - try { - const metadata = agent.buildRuntimePhaseStepMetadata({ - id: "phase-testing", - phaseKey: "testing", - name: "Testing", - position: 2, - instructions: "Run the test suite.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "dedicated", required: true }, - orderingConstraints: { mustFollow: ["development"] }, - requiresApproval: false, - isBuiltIn: true, - isCustom: false, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, "openai/gpt-5.3-codex-spark", "testing-worker"); - - expect(metadata.stepType).toBe("testing"); - expect(metadata.taskType).toBe("testing"); - expect(metadata.modelProviderHint).toBe("codex"); - } finally { - agent.shutdown(); - } - }); - - it("uses the persisted mission prompt when planning questions are required by the mission text", () => { - const agent = createTestCoordinatorAgent({ - phases: createPlanningPhases(), - missionServicePrompt: "Build a FleetOps launch-drill evidence console.", - missionPrompt: [ - "Planning is blocked until the planner asks clarification questions through ADE.", - "Required planning questions before implementation:", - "1. Should this mission use a static app or Vite?", - ].join("\n"), - }) as any; - - try { - const planningPhase = agent.deps.phases.find((phase: any) => phase.phaseKey === "planning"); - const metadata = agent.buildRuntimePhaseStepMetadata( - planningPhase, - "anthropic/claude-sonnet-4-6", - "planning-worker", - ); - - expect(metadata.phaseAskQuestions.requiredBeforeExit).toBe(true); - expect(agent.systemPrompt).toContain("must ask at least one blocking question before finalizing this phase"); - } finally { - agent.shutdown(); - } - }); - - it("accepts a completed ADE-prefixed planner spawn as the startup success path", async () => { - const plannerToolOutput = { - ok: true, - workerId: "worker-planner-1", - launched: true, - stepId: "step-planner-1", - delegationContract: { - schemaVersion: 1, - contractId: "contract-planner-1", - runId: "run-1", - ownerKind: "coordinator", - workerIntent: "planner", - mode: "exclusive", - scope: { kind: "phase", key: "phase:planning", label: "Planning" }, - phaseKey: "planning", - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: ["worker-planner-1"], - coordinatorCapabilities: ["observe", "run_control", "update_mission_state"], - launchPolicy: { maxLaunchAttempts: 2 }, - failurePolicy: { retryLimit: 1, escalation: "intervention" }, - batchId: null, - parentContractId: null, - failure: null, - metadata: {}, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: null, - }, - }; - - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "tool-planner", - callID: "call-planner", - type: "tool", - tool: "ade_spawn_worker", - state: { - status: "completed", - input: { name: "planning-readonly" }, - output: { - content: [{ type: "text", text: JSON.stringify(plannerToolOutput) }], - }, - }, - }, - delta: null, - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const onCoordinatorEvent = vi.fn(); - const onPlanningStartupFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - onPlanningStartupFailure, - phases: createPlanningPhases(), - }) as any; - - try { - agent.injectMessage("Kick off planning."); - await agent.processBatch(); - - expect(onPlanningStartupFailure).not.toHaveBeenCalled(); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "The planning agent is running. I’m waiting for its result.", - })); - expect(agent.isAlive).toBe(true); - } finally { - agent.shutdown(); - } - }); - - it("retries planner launch once on transient provider failures, then surfaces structured failure", async () => { - mockState.eventBatches.push( - [ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "tool-1", - callID: "call-1", - type: "tool", - tool: "spawn_worker", - state: { - status: "error", - error: "Model provider timeout", - input: { name: "planner" }, - }, - }, - delta: null, - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - [ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "tool-2", - callID: "call-2", - type: "tool", - tool: "spawn_worker", - state: { - status: "error", - error: "Model provider timeout", - input: { name: "planner" }, - }, - }, - delta: null, - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - ); - - const onCoordinatorEvent = vi.fn(); - const onPlanningStartupFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - onPlanningStartupFailure, - phases: createPlanningPhases(), - }) as any; - - try { - agent.injectMessage("Kick off planning."); - await agent.processBatch(); - await agent.processBatch(); - - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - turnStatus: "interrupted", - message: "The planner hit a launch issue, so I’m retrying once.", - })); - expect(onPlanningStartupFailure).toHaveBeenCalledWith(expect.objectContaining({ - category: "provider_unreachable", - retryable: true, - retryCount: 1, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - })); - } finally { - agent.shutdown(); - } - }); - - it("promotes short provider failure replies to the primary runtime failure", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-1", - type: "text", - text: "Your account does not have access to Claude. Please login again or contact your administrator.", - }, - delta: "Your account does not have access to Claude. Please login again or contact your administrator.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const onCoordinatorRuntimeFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorRuntimeFailure, - phases: createPlanningPhases(), - }) as any; - - try { - agent.injectMessage("Start the run."); - await agent.processBatch(); - - expect(onCoordinatorRuntimeFailure).toHaveBeenCalledWith(expect.objectContaining({ - category: "provider_unreachable", - reasonCode: "coordinator_runtime_provider_auth_failed", - interventionType: "provider_unreachable", - turnId: "coord-turn-1", - })); - } finally { - agent.shutdown(); - } - }); - - it("classifies missing coordinator models as recoverable provider configuration failures", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-model-not-found", - type: "text", - text: "Model not found: openai/gpt-5.3-codex-spark", - }, - delta: "Model not found: openai/gpt-5.3-codex-spark", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const onCoordinatorRuntimeFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorRuntimeFailure, - phases: createPlanningPhases(), - }) as any; - - try { - agent.injectMessage("Start the run."); - await agent.processBatch(); - - expect(onCoordinatorRuntimeFailure).toHaveBeenCalledWith(expect.objectContaining({ - category: "provider_unreachable", - reasonCode: "coordinator_runtime_model_unavailable", - interventionType: "provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - })); - } finally { - agent.shutdown(); - } - }); - - it("pauses the turn when a blocking ask-user tool result arrives", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "ask-1", - callID: "call-ask-1", - type: "tool", - tool: "ask_user", - state: { - status: "completed", - input: { title: "Clarify" }, - output: { awaitingUserResponse: true, blocking: true }, - }, - }, - delta: null, - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const onCoordinatorEvent = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - }) as any; - - try { - agent.injectMessage("Ask the user."); - await agent.processBatch(); - - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - turnStatus: "interrupted", - turnId: "coord-turn-1", - })); - } finally { - agent.shutdown(); - } - }); - - it("retries planning startup once when the coordinator does not create the planner", async () => { - const plannerToolOutput = { - ok: true, - workerId: "worker-planner-retry", - launched: true, - stepId: "step-planner-retry", - delegationContract: { - schemaVersion: 1, - contractId: "contract-planner-retry", - runId: "run-1", - ownerKind: "coordinator", - workerIntent: "planner", - mode: "exclusive", - scope: { kind: "phase", key: "phase:planning", label: "Planning" }, - phaseKey: "planning", - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: ["worker-planner-retry"], - coordinatorCapabilities: ["observe", "run_control", "update_mission_state"], - launchPolicy: { maxLaunchAttempts: 2 }, - failurePolicy: { retryLimit: 1, escalation: "intervention" }, - batchId: null, - parentContractId: null, - failure: null, - metadata: {}, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: null, - }, - }; - - mockState.eventBatches.push( - [ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "synthetic-prompt", - type: "text", - text: "PLANNING STARTUP REQUIRED: echoed prompt", - synthetic: true, - }, - delta: "PLANNING STARTUP REQUIRED: echoed prompt", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - [ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "tool-planner-retry", - callID: "call-planner-retry", - type: "tool", - tool: "ade_spawn_worker", - state: { - status: "completed", - input: { name: "planning-readonly" }, - output: plannerToolOutput, - }, - }, - delta: null, - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - ); - - const onCoordinatorEvent = vi.fn(); - const onPlanningStartupFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - onPlanningStartupFailure, - phases: createPlanningPhases(), - }) as any; - - try { - agent.injectMessage("Kick off planning."); - await agent.processBatch(); - await agent.processBatch(); - - expect(onPlanningStartupFailure).not.toHaveBeenCalled(); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "The coordinator did not start the planner, so I’m retrying the startup turn once.", - })); - const promptCalls = mockState.promptAsync.mock.calls as any[]; - expect(promptCalls[1]?.[0]?.body?.parts?.[0]?.text).toContain("PLANNING STARTUP RETRY REQUIRED"); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "The planning agent is running. I’m waiting for its result.", - })); - } finally { - agent.shutdown(); - } - }); - - it("launches the required planner through mission control after startup retry is ignored", async () => { - mockState.eventBatches.push( - [{ type: "session.idle", properties: { sessionID: "session-1" } }], - [{ type: "session.idle", properties: { sessionID: "session-1" } }], - ); - - const onCoordinatorEvent = vi.fn(); - const onPlanningStartupFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - onPlanningStartupFailure, - phases: createPlanningPhases(), - }) as any; - const runtimeSpawn = vi.fn(async () => ({ - ok: true, - workerId: "worker-runtime-planner", - launched: true, - stepId: "step-runtime-planner", - delegationContract: { - schemaVersion: 1, - contractId: "contract-runtime-planner", - runId: "run-1", - ownerKind: "coordinator", - workerIntent: "planner", - mode: "exclusive", - scope: { kind: "phase", key: "phase:planning", label: "Planning" }, - phaseKey: "planning", - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: ["worker-runtime-planner"], - coordinatorCapabilities: ["observe", "run_control", "update_mission_state"], - launchPolicy: { maxLaunchAttempts: 2 }, - failurePolicy: { retryLimit: 1, escalation: "intervention" }, - batchId: null, - parentContractId: null, - failure: null, - metadata: {}, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: null, - }, - })); - agent.tools.spawn_worker.execute = runtimeSpawn; - - try { - agent.injectMessage("Kick off planning."); - await agent.processBatch(); - await agent.processBatch(); - - expect(runtimeSpawn).toHaveBeenCalledWith(expect.objectContaining({ - name: "planning-worker", - dependsOn: [], - })); - expect(onPlanningStartupFailure).not.toHaveBeenCalled(); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "The coordinator did not start the planner, so ADE is launching the required planning worker through mission control.", - })); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "The planning agent is running. I’m waiting for its result.", - })); - } finally { - agent.shutdown(); - } - }); - - it("re-drives the coordinator when a tool reports a required phase transition", async () => { - mockState.eventBatches.push( - [ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "tool-spawn-blocked", - callID: "call-spawn-blocked", - type: "tool", - tool: "spawn_worker", - state: { - status: "completed", - input: { name: "implementation-worker" }, - output: { - ok: false, - error: "Planning phase already produced a completed worker result. Call set_current_phase with phaseKey \"implementation_math\" before spawning more workers.", - blockedByPhaseOrdering: true, - phaseTransitionRequired: { - phaseKey: "implementation_math", - reason: "planning_complete", - }, - requiredTool: { - name: "set_current_phase", - args: { - phaseKey: "implementation_math", - reason: "planning_complete", - }, - }, - }, - }, - }, - delta: null, - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - [ - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - ); - - const agent = createTestCoordinatorAgent({ phases: [] }) as any; - - try { - agent.injectMessage("Continue after planning."); - await agent.processBatch(); - await agent.processBatch(); - - expect(mockState.promptAsync).toHaveBeenCalledTimes(2); - const promptCalls = mockState.promptAsync.mock.calls as any[]; - const secondPrompt = promptCalls[1]?.[0]?.body?.parts?.[0]?.text; - expect(secondPrompt).toContain("RUNTIME CORRECTION:"); - expect(secondPrompt).toContain('call set_current_phase with phaseKey "implementation_math"'); - } finally { - agent.shutdown(); - } - }); - - it("re-drives the coordinator when planning succeeded but the phase was not advanced", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-post-planning", - type: "text", - text: 'Step: Set the current planning phase to "implementation_math" as requested.', - }, - delta: 'Step: Set the current planning phase to "implementation_math" as requested.', - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const agent = createTestCoordinatorAgent({ phases: createCustomPlanningPhases() }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "planning"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Planning"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }); - - try { - agent.injectMessage("Planner finished; continue."); - await agent.processBatch(); - - expect(agent.eventQueue[0]?.message).toContain("RUNTIME CORRECTION:"); - expect(agent.eventQueue[0]?.message).toContain('call set_current_phase with phaseKey "implementation_math"'); - } finally { - agent.shutdown(); - } - }); - - it("applies the required post-planning phase transition when the correction is ignored", async () => { - mockState.eventBatches.push( - [ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-post-planning", - type: "text", - text: 'Step: Set the current planning phase to "implementation_math" as requested.', - }, - delta: 'Step: Set the current planning phase to "implementation_math" as requested.', - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - [ - { type: "session.idle", properties: { sessionID: "session-1" } }, - ], - ); - - const onCoordinatorEvent = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - phases: createCustomPlanningPhases(), - }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "planning"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Planning"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }); - const setCurrentPhase = vi.fn(async () => ({ - ok: true, - changed: true, - previousPhaseKey: "planning", - previousPhaseName: "Planning", - currentPhaseKey: "implementation_math", - currentPhaseName: "Implementation math", - })); - agent.tools.set_current_phase.execute = setCurrentPhase; - - try { - agent.injectMessage("Planner finished; continue."); - await agent.processBatch(); - await agent.processBatch(); - - expect(setCurrentPhase).toHaveBeenCalledWith({ - phaseKey: "implementation_math", - reason: "planning_complete", - }); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: 'The coordinator did not enter phase "implementation_math", so ADE is applying the required phase transition through mission control.', - })); - expect(agent.eventQueue[0]?.message).toContain("PHASE TRANSITION COMPLETE:"); - } finally { - agent.shutdown(); - } - }); - - it("replays markdown pseudo tool calls through real coordinator tools", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-pseudo-tools", - type: "text", - text: [ - "```json", - "// multi_tool_use.parallel", - "[", - " {\"name\":\"ade_spawn_worker\",\"arguments\":{\"name\":\"math-worker\",\"modelId\":\"openai/gpt-5.3-codex-spark\",\"lane\":\"lane-1\",\"prompt\":\"Implement math utilities.\",\"dependsOn\":[]}},", - " {\"name\":\"ade_spawn_worker\",\"arguments\":{\"name\":\"strings-worker\",\"modelId\":\"openai/gpt-5.3-codex-spark\",\"prompt\":\"Implement string utilities.\",\"dependsOn\":[]}}", - "]", - "```", - ].join("\n"), - }, - delta: [ - "```json", - "// multi_tool_use.parallel", - "[", - " {\"name\":\"ade_spawn_worker\",\"arguments\":{\"name\":\"math-worker\",\"modelId\":\"openai/gpt-5.3-codex-spark\",\"lane\":\"lane-1\",\"prompt\":\"Implement math utilities.\",\"dependsOn\":[]}},", - " {\"name\":\"ade_spawn_worker\",\"arguments\":{\"name\":\"strings-worker\",\"modelId\":\"openai/gpt-5.3-codex-spark\",\"prompt\":\"Implement string utilities.\",\"dependsOn\":[]}}", - "]", - "```", - ].join("\n"), - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const onCoordinatorEvent = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - phases: createCustomPlanningPhases(), - }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "implementation_math"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Implementation math"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }); - const spawnWorker = vi.fn(async () => ({ - ok: true, - workerId: "worker-replayed", - launched: true, - stepId: "step-replayed", - })); - agent.tools.spawn_worker.execute = spawnWorker; - - try { - agent.injectMessage("Continue implementation."); - await agent.processBatch(); - - expect(spawnWorker).toHaveBeenCalledTimes(2); - expect(spawnWorker).toHaveBeenNthCalledWith(1, expect.objectContaining({ - name: "math-worker", - laneId: "lane-1", - })); - expect(spawnWorker).toHaveBeenNthCalledWith(2, expect.objectContaining({ - name: "strings-worker", - })); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "The coordinator wrote mission-control calls as text, so ADE is replaying those calls through the real mission-control tools.", - })); - } finally { - agent.shutdown(); - } - }); - - it("suppresses recovered spawn_worker calls while existing workers are running", async () => { - const onCoordinatorEvent = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - phases: [], - }) as any; - const graph = agent.__graph; - graph.steps.push( - { - id: "step-running", - runId: "run-1", - missionStepId: null, - stepKey: "dev_end_to_end_tests", - stepIndex: 1, - title: "Create end-to-end Vitest coverage", - laneId: null, - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: null, - metadata: { - phaseKey: "implementation_math", - phaseName: "Implementation math", - stepType: "implementation", - }, - }, - { - id: "step-pending", - runId: "run-1", - missionStepId: null, - stepKey: "validation_worker", - stepIndex: 2, - title: "Validation worker", - laneId: null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: ["step-running"], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: null, - completedAt: null, - metadata: { - phaseKey: "validation", - phaseName: "Validation", - stepType: "validation", - }, - }, - ); - const spawnWorker = vi.fn(async () => ({ - ok: true, - workerId: "worker-stale", - launched: true, - stepId: "step-stale", - })); - agent.tools.spawn_worker.execute = spawnWorker; - - try { - const handled = await agent.replayRecoveredMissionControlText([ - "```json", - "// spawn_worker", - "{\"name\":\"Development worker\",\"prompt\":\"Continue implementation.\"}", - "```", - ].join("\n"), "stale-spawn-turn"); - - expect(handled).toBe(1); - expect(spawnWorker).not.toHaveBeenCalled(); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: "ADE ignored a recovered spawn_worker call because workers are already running and no actionable step needs a new worker.", - })); - } finally { - agent.shutdown(); - } - }); - - it("skips recovered status probes while an exclusive planner delegation is active", async () => { - const onCoordinatorEvent = vi.fn(); - const onPlanningStartupFailure = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - onPlanningStartupFailure, - phases: createPlanningPhases(), - }) as any; - const graph = agent.__graph; - graph.steps.push({ - id: "step-planner-recovery", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_recovery", - stepIndex: 0, - title: "planning-worker-recovery", - laneId: null, - status: "ready", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: null, - completedAt: null, - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - delegationContract: { - schemaVersion: 1, - contractId: "contract-planner-active", - runId: "run-1", - ownerKind: "coordinator", - workerIntent: "planner", - mode: "exclusive", - scope: { kind: "phase", key: "phase:planning", label: "Planning" }, - phaseKey: "planning", - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: ["worker_planning_recovery"], - coordinatorCapabilities: ["observe", "run_control", "update_mission_state"], - launchPolicy: { maxLaunchAttempts: 2 }, - failurePolicy: { retryLimit: 1, escalation: "intervention" }, - batchId: null, - parentContractId: null, - failure: null, - metadata: { launched: false, launchNote: "step_queued_not_yet_started" }, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: null, - completedAt: null, - }, - }, - }); - const sendMessage = vi.fn(async () => ({ ok: true })); - agent.tools.send_message.execute = sendMessage; - - try { - const executed = await agent.replayRecoveredMissionControlText([ - "```json", - "// send_message", - "{\"workerId\":\"worker_planning_recovery\",\"message\":\"Please hurry.\"}", - "```", - ].join("\n"), "manual-recovery-turn"); - - expect(executed).toBe(0); - expect(sendMessage).not.toHaveBeenCalled(); - expect(onPlanningStartupFailure).not.toHaveBeenCalled(); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - turnStatus: "interrupted", - message: expect.stringContaining("Planner delegation is active"), - })); - } finally { - agent.shutdown(); - } - }); - - it("does not self-reprompt after recovered observation tools while workers are already running", async () => { - const agent = createTestCoordinatorAgent({ phases: [] }) as any; - const graph = agent.__graph; - graph.steps.push( - { - id: "step-running", - runId: "run-1", - missionStepId: null, - stepKey: "implementation_worker", - stepIndex: 1, - title: "implementation-worker", - laneId: null, - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: null, - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - }, - }, - { - id: "step-pending", - runId: "run-1", - missionStepId: null, - stepKey: "testing_worker", - stepIndex: 2, - title: "testing-worker", - laneId: null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: ["step-running"], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: null, - completedAt: null, - metadata: { - phaseKey: "testing", - phaseName: "Testing", - stepType: "testing", - }, - }, - ); - agent.tools.list_workers.execute = vi.fn(async () => ({ - ok: true, - workers: [{ workerId: "implementation_worker", state: "working" }], - })); - - try { - const executed = await agent.replayRecoveredMissionControlText([ - "```json", - "// list_workers", - "{}", - "```", - ].join("\n"), "manual-observation-turn"); - - expect(executed).toBe(1); - expect(agent.eventQueue).toEqual([]); - } finally { - agent.shutdown(); - } - }); - - it("keeps re-drive guidance after recovered tools when a step is actionable", async () => { - const agent = createTestCoordinatorAgent({ phases: [] }) as any; - const graph = agent.__graph; - graph.steps.push( - { - id: "step-running", - runId: "run-1", - missionStepId: null, - stepKey: "implementation_worker", - stepIndex: 1, - title: "implementation-worker", - laneId: null, - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: null, - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - }, - }, - { - id: "step-ready", - runId: "run-1", - missionStepId: null, - stepKey: "ready_worker", - stepIndex: 2, - title: "ready-worker", - laneId: null, - status: "ready", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: null, - completedAt: null, - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - }, - }, - ); - agent.tools.list_workers.execute = vi.fn(async () => ({ - ok: true, - workers: [{ workerId: "implementation_worker", state: "working" }], - })); - - try { - const executed = await agent.replayRecoveredMissionControlText([ - "```json", - "// list_workers", - "{}", - "```", - ].join("\n"), "manual-actionable-turn"); - - expect(executed).toBe(1); - expect(agent.eventQueue[0]?.message).toContain("RECOVERED TOOL CALLS EXECUTED:"); - } finally { - agent.shutdown(); - } - }); - - it("starts the configured phase worker when an implementation phase has no executable work", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-no-action", - type: "text", - text: "Implementing changes now.", - }, - delta: "Implementing changes now.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const onCoordinatorEvent = vi.fn(); - const agent = createTestCoordinatorAgent({ - onCoordinatorEvent, - phases: createCustomPlanningPhases(), - }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "implementation_math"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Implementation math"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }); - const spawnWorker = vi.fn(async () => ({ - ok: true, - workerId: "worker-runtime-phase", - launched: true, - stepId: "step-runtime-phase", - })); - agent.tools.spawn_worker.execute = spawnWorker; - - try { - agent.injectMessage("Continue implementation."); - await agent.processBatch(); - - expect(spawnWorker).toHaveBeenCalledWith(expect.objectContaining({ - name: "implementation-math-worker", - modelId: "openai/gpt-5.3-codex-spark", - prompt: expect.stringContaining("Phase instructions:"), - })); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: 'The coordinator did not start work for phase "implementation_math", so ADE is starting the configured phase worker through mission control.', - })); - } finally { - agent.shutdown(); - } - }); - - it("starts ready sibling custom phase workers while current phase work is running", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-parallel-ready-phase", - type: "text", - text: "The math worker is running; continue the implementation DAG.", - }, - delta: "The math worker is running; continue the implementation DAG.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const phases = [ - ...createCustomPlanningPhases(), - { - id: "phase-implementation-strings", - phaseKey: "implementation_strings", - name: "Implementation strings", - position: 2, - instructions: "Implement string work.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["planning"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ]; - const onCoordinatorEvent = vi.fn(); - const agent = createTestCoordinatorAgent({ onCoordinatorEvent, phases }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "implementation_math"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Implementation math"; - graph.steps.push( - { - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, - { - id: "step-math-running", - runId: "run-1", - missionStepId: null, - stepKey: "worker_implementation_math_1", - stepIndex: 1, - title: "implementation-math-worker", - laneId: null, - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: ["step-planner-succeeded"], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:01.000Z", - updatedAt: "2026-03-12T00:00:02.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: null, - metadata: { - phaseKey: "implementation_math", - phaseName: "Implementation math", - stepType: "implementation", - }, - }, - ); - - try { - agent.injectMessage("Keep going."); - await agent.processBatch(); - - const stringStep = graph.steps.find((step: any) => step.metadata?.phaseKey === "implementation_strings"); - expect(stringStep).toEqual(expect.objectContaining({ - title: "implementation-strings-worker", - executorKind: "codex", - })); - expect(stringStep?.metadata).toEqual(expect.objectContaining({ - modelId: "openai/gpt-5.3-codex-spark", - modelProviderHint: "codex", - workerName: "implementation-strings-worker", - })); - expect((agent as any).deps.orchestratorService.startReadyAutopilotAttempts).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - reason: "ready_phase_parallel_runtime_spawn", - })); - expect(onCoordinatorEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: "status", - message: 'ADE is starting ready phase "implementation_strings" in parallel because its phase dependencies are satisfied.', - })); - } finally { - agent.shutdown(); - } - }); - - it("records self-validation from a successful worker result before advancing phases", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-self-validate", - type: "text", - text: "Math work is done; moving on.", - }, - delta: "Math work is done; moving on.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const phases = [ - ...createCustomPlanningPhases(), - { - id: "phase-implementation-strings", - phaseKey: "implementation_strings", - name: "Implementation strings", - position: 2, - instructions: "Implement string work.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["implementation_math"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ]; - const agent = createTestCoordinatorAgent({ phases }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "implementation_math"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Implementation math"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, { - id: "step-math-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "implementation-math-worker", - stepIndex: 1, - title: "implementation-math-worker", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:02.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: "2026-03-12T00:00:02.000Z", - metadata: { - phaseKey: "implementation_math", - phaseName: "Implementation math", - stepType: "implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Math changes must pass tests.", - evidence: [], - maxRetries: 2, - }, - lastResultReport: { - outcome: "succeeded", - summary: "Added math helpers and tests.", - filesChanged: ["src/math.js", "test/math.test.js"], - testsRun: { passed: 9, failed: 0, skipped: 0 }, - }, - }, - }); - const reportValidation = vi.fn(async () => ({ ok: true })); - const setCurrentPhase = vi.fn(async () => ({ ok: true, currentPhaseName: "Implementation strings" })); - agent.tools.report_validation.execute = reportValidation; - agent.tools.set_current_phase.execute = setCurrentPhase; - - try { - agent.injectMessage("Continue implementation."); - await agent.processBatch(); - - expect(reportValidation).toHaveBeenCalledWith(expect.objectContaining({ - targetWorkerId: "implementation-math-worker", - verdict: "pass", - summary: expect.stringContaining("Added math helpers and tests."), - })); - expect(setCurrentPhase).not.toHaveBeenCalled(); - } finally { - agent.shutdown(); - } - }); - - it("defers transient failed tests from parallel implementation self-validation to final testing", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-self-validate-transient", - type: "text", - text: "String work is done; the full suite will be clean once the math sibling lands.", - }, - delta: "String work is done; the full suite will be clean once the math sibling lands.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const phases = [ - ...createCustomPlanningPhases(), - { - id: "phase-implementation-strings", - phaseKey: "implementation_strings", - name: "Implementation strings", - position: 2, - instructions: "Implement string work.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["planning"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - { - id: "phase-testing", - phaseKey: "testing", - name: "Testing", - position: 3, - instructions: "Run final integration tests.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["implementation_math", "implementation_strings"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ]; - const agent = createTestCoordinatorAgent({ phases }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "implementation_strings"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Implementation strings"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, { - id: "step-strings-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "implementation-strings-worker", - stepIndex: 2, - title: "implementation-strings-worker", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:01.000Z", - updatedAt: "2026-03-12T00:00:02.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: "2026-03-12T00:00:02.000Z", - metadata: { - phaseKey: "implementation_strings", - phaseName: "Implementation strings", - stepType: "implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "String changes must pass final tests.", - evidence: [], - maxRetries: 2, - }, - lastResultReport: { - outcome: "succeeded", - summary: "Added string helpers; the remaining failure is from the parallel math phase.", - filesChanged: ["src/strings.js", "test/strings.test.js"], - testsRun: { passed: 9, failed: 1, skipped: 0 }, - }, - }, - }); - const reportValidation = vi.fn(async () => ({ ok: true })); - agent.tools.report_validation.execute = reportValidation; - - try { - agent.injectMessage("Continue implementation."); - await agent.processBatch(); - - expect(reportValidation).toHaveBeenCalledWith(expect.objectContaining({ - targetWorkerId: "implementation-strings-worker", - verdict: "pass", - summary: expect.stringContaining("deferred to the final validation phase"), - findings: [expect.objectContaining({ code: "integration_tests_deferred" })], - })); - } finally { - agent.shutdown(); - } - }); - - it("does not advance a phase while a succeeded step still needs required validation", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-dedicated-validation", - type: "text", - text: "Implementation is complete; moving to strings.", - }, - delta: "Implementation is complete; moving to strings.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const phases = [ - ...createCustomPlanningPhases(), - { - id: "phase-implementation-strings", - phaseKey: "implementation_strings", - name: "Implementation strings", - position: 2, - instructions: "Implement string work.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["implementation_math"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ]; - const agent = createTestCoordinatorAgent({ phases }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "implementation_math"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Implementation math"; - graph.steps.push({ - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, { - id: "step-math-needs-validator", - runId: "run-1", - missionStepId: null, - stepKey: "implementation-math-worker", - stepIndex: 1, - title: "implementation-math-worker", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:02.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: "2026-03-12T00:00:02.000Z", - metadata: { - phaseKey: "implementation_math", - phaseName: "Implementation math", - stepType: "implementation", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Dedicated validation must pass.", - evidence: [], - maxRetries: 2, - }, - lastResultReport: { - outcome: "succeeded", - summary: "Added math helpers and tests.", - testsRun: { passed: 9, failed: 0, skipped: 0 }, - }, - }, - }); - const setCurrentPhase = vi.fn(async () => ({ ok: true, currentPhaseName: "Implementation strings" })); - agent.tools.set_current_phase.execute = setCurrentPhase; - - try { - agent.injectMessage("Continue implementation."); - await agent.processBatch(); - - expect(setCurrentPhase).not.toHaveBeenCalled(); - } finally { - agent.shutdown(); - } - }); - - it("calls complete_mission when the final configured phase is terminal and validated", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-final-phase-complete", - type: "text", - text: "Closeout is done.", - }, - delta: "Closeout is done.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const phases = [ - ...createCustomPlanningPhases(), - { - id: "phase-closeout", - phaseKey: "closeout", - name: "Closeout", - position: 2, - instructions: "Summarize the completed lane and tests.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "self", required: true }, - orderingConstraints: { mustFollow: ["implementation_math"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ]; - const agent = createTestCoordinatorAgent({ phases }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "closeout"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Closeout"; - graph.steps.push( - { - id: "step-planner-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "worker_planning_1", - stepIndex: 0, - title: "planning-worker", - laneId: null, - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - }, - }, - { - id: "step-impl-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "implementation-math-worker", - stepIndex: 1, - title: "implementation-math-worker", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:01.000Z", - updatedAt: "2026-03-12T00:00:02.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: "2026-03-12T00:00:02.000Z", - metadata: { - phaseKey: "implementation_math", - phaseName: "Implementation math", - stepType: "implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Implementation must pass.", - evidence: [], - maxRetries: 2, - }, - validationState: "pass", - lastValidationReport: { verdict: "pass" }, - validationPassedAt: "2026-03-12T00:00:02.000Z", - lastResultReport: { - outcome: "succeeded", - summary: "Implemented the requested utility.", - }, - }, - }, - { - id: "step-closeout-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "closeout-worker", - stepIndex: 2, - title: "closeout-worker", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:02.000Z", - updatedAt: "2026-03-12T00:00:03.000Z", - startedAt: "2026-03-12T00:00:02.000Z", - completedAt: "2026-03-12T00:00:03.000Z", - metadata: { - phaseKey: "closeout", - phaseName: "Closeout", - stepType: "implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Closeout must summarize the lane.", - evidence: [], - maxRetries: 2, - }, - validationState: "pass", - lastValidationReport: { verdict: "pass" }, - validationPassedAt: "2026-03-12T00:00:03.000Z", - lastResultReport: { - outcome: "succeeded", - summary: "Closeout verified the lane and npm test passed.", - }, - }, - }, - ); - const completeMission = vi.fn(async () => ({ ok: true, runId: "run-1", finalStatus: "succeeded" })); - agent.tools.complete_mission.execute = completeMission; - - try { - agent.injectMessage("Finalize if ready."); - await agent.processBatch(); - - expect(completeMission).toHaveBeenCalledWith({ - summary: "Closeout verified the lane and npm test passed.", - }); - } finally { - agent.shutdown(); - } - }); - - it("does not call complete_mission while final phase validation is still pending", async () => { - mockState.eventBatches.push([ - { - type: "message.part.updated", - properties: { - part: { - sessionID: "session-1", - id: "text-final-phase-validation-pending", - type: "text", - text: "Closeout is waiting on validation.", - }, - delta: "Closeout is waiting on validation.", - }, - }, - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const phases = [ - ...createCustomPlanningPhases(), - { - id: "phase-closeout", - phaseKey: "closeout", - name: "Closeout", - position: 2, - instructions: "Summarize the completed lane and tests.", - model: { modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - askQuestions: { enabled: false, maxQuestions: 0 }, - validationGate: { tier: "dedicated", required: true }, - orderingConstraints: { mustFollow: ["implementation_math"] }, - requiresApproval: false, - isBuiltIn: false, - isCustom: true, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - }, - ]; - const agent = createTestCoordinatorAgent({ phases }) as any; - const graph = agent.__graph; - graph.run.metadata.phaseRuntime.currentPhaseKey = "closeout"; - graph.run.metadata.phaseRuntime.currentPhaseName = "Closeout"; - graph.steps.push({ - id: "step-closeout-succeeded", - runId: "run-1", - missionStepId: null, - stepKey: "closeout-worker", - stepIndex: 0, - title: "closeout-worker", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: { - phaseKey: "closeout", - phaseName: "Closeout", - stepType: "implementation", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Closeout needs dedicated validation.", - evidence: [], - maxRetries: 2, - }, - lastResultReport: { - outcome: "succeeded", - summary: "Closeout wrote the summary.", - }, - }, - }); - const completeMission = vi.fn(async () => ({ ok: true, runId: "run-1", finalStatus: "succeeded" })); - agent.tools.complete_mission.execute = completeMission; - - try { - agent.injectMessage("Do not finalize until validation passes."); - await agent.processBatch(); - - expect(completeMission).not.toHaveBeenCalled(); - } finally { - agent.shutdown(); - } - }); - - it("releases idle coordinator sessions without shutting the agent down", async () => { - vi.useFakeTimers(); - mockState.eventBatches.push([ - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const agent = createTestCoordinatorAgent() as any; - - try { - const initialStartCalls = vi.mocked(startOpenCodeSession).mock.calls.length; - agent.injectMessage("Start planning."); - await vi.advanceTimersByTimeAsync(250); - - const afterFirstBatchCalls = vi.mocked(startOpenCodeSession).mock.calls.length; - expect(afterFirstBatchCalls).toBeGreaterThan(initialStartCalls); - - await vi.advanceTimersByTimeAsync(5 * 60 * 1000); - expect(mockState.close.mock.calls.length).toBeGreaterThan(0); - - expect(agent.isAlive).toBe(true); - } finally { - agent.shutdown(); - vi.useRealTimers(); - } - }); - - it("releases paused OpenCode coordinator sessions when the idle timer fires", async () => { - vi.useFakeTimers(); - const agent = createTestCoordinatorAgent({ - runStatus: "paused", - }) as any; - - try { - const handle = await agent.ensureOpenCodeCoordinatorSession(); - expect(handle.setEvictionHandler).toHaveBeenCalledWith(expect.any(Function)); - - await vi.advanceTimersByTimeAsync(5 * 60 * 1000); - - expect(handle.setEvictionHandler).toHaveBeenCalledWith(null); - expect(handle.setBusy).toHaveBeenCalledWith(false); - expect(handle.close).toHaveBeenCalledWith("paused_run"); - } finally { - agent.shutdown(); - vi.useRealTimers(); - } - }); - - it("maps an attach_failed eviction reason to handle_close when releasing the coordinator session", async () => { - const agent = createTestCoordinatorAgent() as any; - - try { - const handle = await agent.ensureOpenCodeCoordinatorSession(); - const evictionHandler = (handle.setEvictionHandler as any).mock.calls[0]?.[0] as ((reason: string) => void) | undefined; - expect(evictionHandler).toBeTypeOf("function"); - - evictionHandler?.("attach_failed"); - - expect(handle.close).toHaveBeenCalledWith("handle_close"); - expect(handle.close).not.toHaveBeenCalledWith("attach_failed"); - } finally { - agent.shutdown(); - } - }); - - it("keeps OpenCode eviction handlers bound to the handle instance that registered them", async () => { - const agent = createTestCoordinatorAgent() as any; - - try { - const firstHandle = await agent.ensureOpenCodeCoordinatorSession(); - const firstEvictionHandler = (firstHandle.setEvictionHandler as any).mock.calls[0]?.[0] as ((reason: string) => void) | undefined; - expect(firstEvictionHandler).toBeTypeOf("function"); - - (agent as any).releaseOpenCodeCoordinatorSession("handle_close"); - const secondHandle = await agent.ensureOpenCodeCoordinatorSession(); - const secondCloseCallCount = secondHandle.close.mock.calls.length; - - firstEvictionHandler?.("error"); - - expect(secondHandle.close.mock.calls.length).toBe(secondCloseCallCount); - expect(firstHandle.close).toHaveBeenCalledWith("handle_close"); - } finally { - agent.shutdown(); - } - }); - - it("runs GPT-5.5 coordinators through the shared agent chat runtime", async () => { - const initialStartCalls = vi.mocked(startOpenCodeSession).mock.calls.length; - const createSession = vi.fn(async () => ({ id: "chat-coordinator-1" })); - const runSessionTurn = vi.fn(async (_args: any) => ({ outputText: "Coordinator acknowledged." })); - const interrupt = vi.fn(async () => undefined); - const dispose = vi.fn(async () => undefined); - const onCoordinatorChatSessionStarted = vi.fn(); - const onCoordinatorChatRuntimeTurnStarted = vi.fn(); - const onCoordinatorChatRuntimeTurnFinished = vi.fn(); - const agent = createTestCoordinatorAgent({ - modelId: "openai/gpt-5.5", - missionLaneId: "lane-mission-1", - coordinatorReasoningEffort: "low", - onCoordinatorChatSessionStarted, - onCoordinatorChatRuntimeTurnStarted, - onCoordinatorChatRuntimeTurnFinished, - agentChatService: { - createSession, - runSessionTurn, - interrupt, - dispose, - }, - }) as any; - - try { - agent.injectMessage("Start the mission with the selected model."); - await agent.processBatch(); - - expect(vi.mocked(startOpenCodeSession).mock.calls.length).toBe(initialStartCalls); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - laneId: "lane-mission-1", - provider: "codex", - model: "gpt-5.5", - modelId: "openai/gpt-5.5", - title: "Mission coordinator", - sessionProfile: "workflow", - surface: "mission", - reasoningEffort: "low", - permissionMode: "plan", - })); - expect(onCoordinatorChatSessionStarted).toHaveBeenCalledWith({ - sessionId: "chat-coordinator-1", - missionId: "mission-1", - runId: "run-1", - laneId: "lane-mission-1", - }); - expect(onCoordinatorChatRuntimeTurnStarted).toHaveBeenCalledWith({ - sessionId: "chat-coordinator-1", - missionId: "mission-1", - runId: "run-1", - laneId: "lane-mission-1", - }); - expect(onCoordinatorChatRuntimeTurnFinished).toHaveBeenCalledWith({ - sessionId: "chat-coordinator-1", - missionId: "mission-1", - runId: "run-1", - laneId: "lane-mission-1", - }); - expect(runSessionTurn).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: "chat-coordinator-1", - displayText: "ADE coordinator start: initialize the mission.", - reasoningEffort: "low", - text: expect.stringContaining("ADE Mission-Control Tool Transport"), - })); - expect(runSessionTurn.mock.calls[0]?.[0]?.text).toContain("Start the mission with the selected model."); - } finally { - agent.shutdown(); - } - }); - - it("replays chat-runtime fenced mission-control calls and keeps recovery guidance transport-aware", async () => { - const createSession = vi.fn(async () => ({ id: "chat-coordinator-1" })); - const runSessionTurn = vi.fn(async (_args: any) => ({ - outputText: [ - "```json", - "// read_mission_status", - "{}", - "```", - ].join("\n"), - })); - const agent = createTestCoordinatorAgent({ - modelId: "openai/gpt-5.5", - missionLaneId: "lane-mission-1", - agentChatService: { - createSession, - runSessionTurn, - interrupt: vi.fn(async () => undefined), - dispose: vi.fn(async () => undefined), - }, - }) as any; - const readMissionStatus = vi.fn(async () => ({ ok: true, status: "active" })); - agent.tools.read_mission_status.execute = readMissionStatus; - - try { - agent.injectMessage("Call read_mission_status immediately after resume."); - await agent.processBatch(); - - expect(readMissionStatus).toHaveBeenCalledTimes(1); - expect(runSessionTurn.mock.calls[0]?.[0]?.text).toContain( - "do not report that tools are unavailable", - ); - expect(agent.eventQueue[0]?.message).toContain( - "Recovered tool outputs:", - ); - expect(agent.eventQueue[0]?.message).toContain( - '"status":"active"', - ); - expect(agent.eventQueue[0]?.message).toContain( - "Do not repeat the same read-only status probes", - ); - expect(agent.eventQueue[0]?.message).not.toContain( - "Do not describe tool calls in markdown/code fences", - ); - } finally { - agent.shutdown(); - } - }); - - it("replays recovered skip_step calls that use stepKey or stepId aliases", async () => { - const agent = createTestCoordinatorAgent() as any; - const graph = agent.__graph; - graph.steps.push({ - id: "step-validation", - runId: "run-1", - missionStepId: null, - stepKey: "validate-mission-output", - stepIndex: 1, - title: "Validate mission output", - laneId: null, - status: "failed", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 1, - lastAttemptId: null, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:00.000Z", - completedAt: "2026-03-12T00:00:01.000Z", - metadata: {}, - }); - - try { - const executed = await agent.replayRecoveredMissionControlText([ - "```json", - "// skip_step", - "{\"stepKey\":\"validate-mission-output\",\"reason\":\"stale validation worker already covered\"}", - "```", - ].join("\n"), "turn-skip-alias"); - - expect(executed).toBe(1); - expect(graph.steps.find((step: { stepKey: string }) => step.stepKey === "validate-mission-output")?.status).toBe("skipped"); - expect(agent.eventQueue[0]?.message).toContain('"newStatus":"skipped"'); - } finally { - agent.shutdown(); - } - }); - - it("recovers a startup spawn_worker block whose prompt contains nested markdown fences", async () => { - const agent = createTestCoordinatorAgent({ phases: createPlanningPhases() }) as any; - const getProjectContext = vi.fn(async () => ({ ok: true, projectRoot: "./" })); - const spawnWorker = vi.fn(async () => ({ - ok: true, - workerId: "worker-nested-fence-planner", - launched: true, - stepId: "step-nested-fence-planner", - delegationContract: { - schemaVersion: 1, - contractId: "contract-nested-fence-planner", - runId: "run-1", - ownerKind: "coordinator", - workerIntent: "planner", - mode: "exclusive", - scope: { kind: "phase", key: "phase:planning", label: "Planning" }, - phaseKey: "planning", - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: ["worker-nested-fence-planner"], - coordinatorCapabilities: ["observe", "run_control", "update_mission_state"], - launchPolicy: { maxLaunchAttempts: 2 }, - failurePolicy: { retryLimit: 1, escalation: "intervention" }, - batchId: null, - parentContractId: null, - failure: null, - metadata: {}, - createdAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - startedAt: "2026-03-12T00:00:01.000Z", - completedAt: null, - }, - })); - agent.tools.get_project_context.execute = getProjectContext; - agent.tools.spawn_worker.execute = spawnWorker; - - try { - const spawnArgs = { - laneId: "lane-1", - modelId: "openai/gpt-5.5", - phase: "planning", - readOnly: true, - prompt: [ - "Plan this implementation.", - "```ts", - "function example(): string { return \"ok\"; }", - "```", - ].join("\n"), - }; - const executed = await agent.replayRecoveredMissionControlText([ - "```json", - "// get_project_context", - "{\"includeFileTree\":true}", - "```", - "", - "```json", - "// spawn_worker", - JSON.stringify(spawnArgs, null, 2), - "```", - ].join("\n"), "turn-nested-fence-startup"); - - expect(executed).toBe(2); - expect(getProjectContext).toHaveBeenCalledTimes(1); - expect(spawnWorker).toHaveBeenCalledTimes(1); - expect(spawnWorker).toHaveBeenCalledWith(expect.objectContaining({ - laneId: "lane-1", - modelId: "openai/gpt-5.5", - prompt: expect.stringContaining("```ts"), - })); - } finally { - agent.shutdown(); - } - }); - - it("starts the OpenCode coordinator through the shared runtime lease", async () => { - mockState.eventBatches.push([ - { type: "session.idle", properties: { sessionID: "session-1" } }, - ]); - - const agent = createTestCoordinatorAgent({ - phases: createPlanningPhases(), - }) as any; - - try { - agent.injectMessage("Start planning."); - await agent.processBatch(); - - const startCalls = vi.mocked(startOpenCodeSession).mock.calls; - expect(startCalls.length).toBeGreaterThan(0); - expect(startCalls[0]?.[0]?.leaseKind).toBe("shared"); - expect(Object.keys(startCalls[0]?.[0] ?? {}).sort()).toEqual(expect.arrayContaining(["directory", "leaseKind", "ownerId", "ownerKind"])); - expect(mockState.promptAsync).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent: "ade-plan", - tools: expect.objectContaining({ task: false }), - parts: [expect.objectContaining({ - text: expect.stringContaining("PLANNING STARTUP REQUIRED"), - })], - }), - })); - } finally { - agent.shutdown(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts deleted file mode 100644 index c66a2a50f..000000000 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ /dev/null @@ -1,4338 +0,0 @@ -// --------------------------------------------------------------------------- -// Coordinator Agent — the AI brain of the orchestrator. -// A long-running AI agent with full authority to plan, spawn workers, -// monitor progress, steer execution, and complete missions autonomously. -// --------------------------------------------------------------------------- - -import { randomUUID } from "node:crypto"; -import type { RuntimeModelMessage as ModelMessage } from "../chat/runtimeMessageTypes"; -import { - classifyPlannerLaunchFailure, - COORDINATOR_TOOL_NAMES, - createCoordinatorToolSet, - type CoordinatorExecutableTool, - type CoordinatorSendWorkerMessageFn, -} from "./coordinatorTools"; -import { - buildOpenCodePromptParts, - mapPermissionModeToOpenCodeAgent, - openCodeEventStream, - refreshOpenCodeSessionToolSelection, - resolveOpenCodeModelSelection, - startOpenCodeSession, - type OpenCodeSessionHandle, -} from "../opencode/openCodeRuntime"; -import { - formatRuntimeEvent, -} from "./coordinatorEventFormatter"; -import { - createCompactionMonitor, - compactConversation, - type CompactionMonitor, - type TranscriptEntry, -} from "../ai/compactionEngine"; -import { asRecord, filterExecutionSteps, TERMINAL_STEP_STATUSES } from "./orchestratorContext"; -import { readMissionStateDocument, writeCoordinatorCheckpoint } from "./missionStateDoc"; -import { - classifyWorkerExecutionPath, - getLocalProviderDefaultEndpoint, - resolveModelDescriptor, - resolveProviderGroupForModel, -} from "../../../shared/modelRegistry"; -import { inspectLocalProvider } from "../ai/localModelDiscovery"; -import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime"; -import type { createOrchestratorService } from "./orchestratorService"; -import type { - AgentChatEvent, - DelegationContract, - DelegationFailureCategory, - DagMutationEvent, - MissionBudgetSnapshot, - MissionInterventionType, - OrchestratorRuntimeEvent, - OrchestratorStep, - OrchestratorStepStatus, - PhaseCard, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { createMissionService } from "../missions/missionService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { createAgentChatService } from "../chat/agentChatService"; -import { - checkCoordinatorToolPermission, - createDelegationContract, - createDelegationScope, - derivePlanningStartupStateFromContract, - extractDelegationContract, - extractActiveDelegationContracts, - normalizeCoordinatorToolName, - updateDelegationContract, -} from "./delegationContracts"; -import { findPhaseByRef, resolveMustPrecedePredecessors } from "../missions/phaseEngine"; -import { - hasResolvedPlanningQuestionIntervention, - phaseRequiresPlanningQuestionBeforeExit, -} from "./planningQuestionPolicy"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** User-configured rules that constrain the coordinator's behavior. */ -export type CoordinatorUserRules = { - providerPreference?: string; - costMode?: string; - maxParallelWorkers?: number; - allowParallelAgents?: boolean; - allowSubAgents?: boolean; - allowClaudeAgentTeams?: boolean; - laneStrategy?: string; - customInstructions?: string; - coordinatorModel?: string; - closeoutContract?: string; - budgetLimitUsd?: number; - budgetLimitTokens?: number; - recoveryEnabled?: boolean; - recoveryMaxIterations?: number; -}; - -/** Project context provided to the coordinator at startup. */ -export type CoordinatorProjectContext = { - projectRoot: string; - projectDocPaths?: string[]; - projectHints?: string[]; - fileTree?: string; -}; - -/** Available provider info passed to the coordinator. */ -export type CoordinatorAvailableProvider = { - name: string; - available: boolean; - models?: string[]; -}; - -export type CoordinatorAgentDeps = { - orchestratorService: ReturnType; - runId: string; - missionId: string; - missionGoal: string; - modelId: string; - logger: Logger; - db: AdeDb; - projectId: string; - projectRoot: string; - workspaceRoot: string; - missionService: ReturnType; - aiIntegrationService: Pick, "executeTask"> | null; - agentChatService?: Pick< - ReturnType, - "createSession" | "runSessionTurn" | "interrupt" | "dispose" - > | null; - projectConfigService?: ReturnType | null; - getMissionBudgetStatus?: () => Promise; - onDagMutation: (event: DagMutationEvent) => void; - onCoordinatorMessage?: (message: string) => void; - onCoordinatorEvent?: (event: AgentChatEvent) => void; - onRunFinalize?: (args: { runId: string; succeeded: boolean; summary?: string; reason?: string }) => void; - enableCompaction?: boolean; - userRules?: CoordinatorUserRules; - projectContext?: CoordinatorProjectContext; - availableProviders?: CoordinatorAvailableProvider[]; - /** Phase cards defining the mission execution phases — injected into the coordinator prompt. */ - phases?: PhaseCard[]; - /** Called when spawn_worker detects a budget hard cap. Orchestrator creates a pause intervention. */ - onHardCapTriggered?: (detail: string) => void; - /** Called when budget pressure transitions to warning or critical. Emits a soft warning chat message. */ - onBudgetWarning?: (pressure: "warning" | "critical", detail: string) => void; - /** Runtime worker delivery bridge used by coordinator messaging tools. */ - sendWorkerMessageToSession?: CoordinatorSendWorkerMessageFn; - /** Primary mission lane ID — all mission work should happen in this lane (or children of it). */ - missionLaneId?: string; - /** Reasoning effort selected by the mission picker for the coordinator model. */ - coordinatorReasoningEffort?: string | null; - /** Registers the Work-chat session backing the coordinator runtime. */ - onCoordinatorChatSessionStarted?: (args: { sessionId: string; missionId: string; runId: string; laneId: string | null }) => void; - /** Marks autonomous coordinator Work-chat turns so external chat observers do not replay them twice. */ - onCoordinatorChatRuntimeTurnStarted?: (args: { sessionId: string; missionId: string; runId: string; laneId: string | null }) => void; - onCoordinatorChatRuntimeTurnFinished?: (args: { sessionId: string; missionId: string; runId: string; laneId: string | null }) => void; - /** Callback to create a new lane branching from the mission's base lane. */ - provisionLane?: (name: string, description?: string) => Promise<{ laneId: string; name: string }>; - onPlanningStartupFailure?: (failure: CoordinatorPlanningStartupFailure) => void; - onCoordinatorRuntimeFailure?: (failure: CoordinatorRuntimeFailure) => void; -}; - -export type PlanningStartupState = - | "inactive" - | "awaiting_project_context" - | "awaiting_planner_launch" - | "waiting_on_planner" - | "failed"; - -export type CoordinatorPlanningStartupFailure = { - category: DelegationFailureCategory; - reasonCode: string; - interventionType: MissionInterventionType; - retryable: boolean; - recoveryOptions: Array<"retry" | "switch_to_fallback_model" | "cancel_run">; - message: string; - title: string; - body: string; - requestedAction: string; - toolName?: string | null; - retryCount: number; -}; - -export type CoordinatorRuntimeFailureCategory = - | "provider_unreachable" - | "permission_denied" - | "cli_runtime_failure" - | "unknown"; - -export type CoordinatorRuntimeFailure = { - category: CoordinatorRuntimeFailureCategory; - reasonCode: string; - interventionType: MissionInterventionType; - retryable: boolean; - recoveryOptions: Array<"retry" | "switch_to_fallback_model" | "cancel_run">; - message: string; - title: string; - body: string; - requestedAction: string; - turnId: string; -}; - -type QueuedEvent = { - message: string; - receivedAt: number; - retryCount?: number; -}; - -type PhaseTransitionRequiredSignal = { - phaseKey: string; - reason: string; -}; - -type SelfValidationDecision = { - verdict: "pass" | "fail"; - summary: string; - findings: Array<{ - code: string; - severity: "low" | "medium" | "high"; - message: string; - remediation?: string; - }>; -}; - -type RecoveredCoordinatorToolCall = { - toolName: string; - args: Record; -}; - -type RecoveredToolExecutionContext = { - hadRunningWorkers: boolean; - hadActionableSteps: boolean; -}; - -const RECOVERED_PARALLEL_TOOL_NAMES = new Set(["parallel", "multi_tool_use.parallel"]); - -function isRecoveredCoordinatorToolLabel(label: string): boolean { - const normalized = normalizeCoordinatorToolName(label).trim(); - if (RECOVERED_PARALLEL_TOOL_NAMES.has(normalized.toLowerCase())) return true; - return (COORDINATOR_TOOL_NAMES as readonly string[]).includes(normalized); -} - -function parseJsonObject(value: string): Record | null { - try { - return asRecord(JSON.parse(value)); - } catch { - return null; - } -} - -function coerceToolOutputRecord(value: unknown): Record | null { - const direct = asRecord(value); - if (direct?.ok !== undefined || direct?.error !== undefined || direct?.delegationContract !== undefined) { - return direct; - } - - if (typeof value === "string") { - return parseJsonObject(value); - } - - const nestedOutput = direct ? direct.output : undefined; - if (nestedOutput !== undefined) { - const nested = coerceToolOutputRecord(nestedOutput); - if (nested) return nested; - } - - const content = Array.isArray(direct?.content) ? direct.content : []; - for (const entry of content) { - const item = asRecord(entry); - const text = typeof item?.text === "string" ? item.text.trim() : ""; - if (!text) continue; - const parsed = parseJsonObject(text); - if (parsed) return parsed; - } - - return direct; -} - -function stringifyRecoveredToolOutputForPrompt(value: unknown): string { - let serialized: string; - try { - serialized = JSON.stringify(value); - } catch { - serialized = String(value ?? "null"); - } - if (serialized.length <= RECOVERED_TOOL_RESULT_MAX_CHARS) return serialized; - return `${serialized.slice(0, RECOVERED_TOOL_RESULT_MAX_CHARS)}...[truncated]`; -} - -function joinRecoveredToolResultSummaries(summaries: string[]): string { - const joined = summaries.join("\n"); - if (joined.length <= RECOVERED_TOOL_RESULTS_MAX_CHARS) return joined; - return `${joined.slice(0, RECOVERED_TOOL_RESULTS_MAX_CHARS)}\n[recovered tool results truncated]`; -} - -function extractPhaseTransitionRequiredSignal(value: Record | null): PhaseTransitionRequiredSignal | null { - if (!value) return null; - const direct = asRecord(value.phaseTransitionRequired); - const directPhaseKey = typeof direct?.phaseKey === "string" ? direct.phaseKey.trim() : ""; - const directReason = typeof direct?.reason === "string" ? direct.reason.trim() : ""; - if (directPhaseKey.length > 0) { - return { - phaseKey: directPhaseKey, - reason: directReason || "planning_complete", - }; - } - - const requiredTool = asRecord(value.requiredTool); - const requiredToolName = typeof requiredTool?.name === "string" ? requiredTool.name.trim() : ""; - const requiredArgs = asRecord(requiredTool?.args); - const toolPhaseKey = typeof requiredArgs?.phaseKey === "string" ? requiredArgs.phaseKey.trim() : ""; - const toolReason = typeof requiredArgs?.reason === "string" ? requiredArgs.reason.trim() : ""; - if (requiredToolName === "set_current_phase" && toolPhaseKey.length > 0) { - return { - phaseKey: toolPhaseKey, - reason: toolReason || "planning_complete", - }; - } - - return null; -} - -function parsePseudoToolJson(value: string): unknown { - const withoutLineComments = value - .split(/\r?\n/) - .filter((line) => !line.trim().startsWith("//")) - .join("\n") - .trim(); - if (!withoutLineComments.length) return null; - return JSON.parse(withoutLineComments); -} - -function findJsonValueStart(value: string, fromIndex: number): number { - for (let index = fromIndex; index < value.length; index += 1) { - const char = value[index]; - if (char === "{" || char === "[") return index; - if (char === "`") continue; - if (/\s/.test(char ?? "")) continue; - return -1; - } - return -1; -} - -function extractBalancedJsonValue(value: string, startIndex: number): { raw: string; endIndex: number } | null { - const open = value[startIndex]; - const close = open === "{" ? "}" : open === "[" ? "]" : ""; - if (!close) return null; - - let depth = 0; - let inString = false; - let escaped = false; - for (let index = startIndex; index < value.length; index += 1) { - const char = value[index] ?? ""; - if (inString) { - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === "\"") { - inString = false; - } - continue; - } - if (char === "\"") { - inString = true; - continue; - } - if (char === open) depth += 1; - if (char === close) { - depth -= 1; - if (depth === 0) { - return { - raw: value.slice(startIndex, index + 1), - endIndex: index + 1, - }; - } - } - } - return null; -} - -function readRecoveredCallName(record: Record | null): string { - if (typeof record?.name === "string") return record.name.trim(); - if (typeof record?.recipient_name === "string") return record.recipient_name.trim(); - return ""; -} - -function pushRecoveredParsedCall( - calls: RecoveredCoordinatorToolCall[], - toolName: string, - parsed: unknown, -): void { - const normalizedLabel = toolName.trim(); - if (/^(?:multi_tool_use\.)?parallel$/i.test(normalizedLabel)) { - const entries = Array.isArray(parsed) ? parsed : []; - for (const entry of entries) { - const raw = asRecord(entry); - const rawName = readRecoveredCallName(raw); - const args = asRecord(raw?.arguments ?? raw?.parameters ?? raw?.args) ?? {}; - if (rawName.length > 0) calls.push({ toolName: rawName, args }); - } - return; - } - - const parsedRecord = asRecord(parsed); - if (normalizedLabel.length > 0 && parsedRecord) { - calls.push({ toolName: normalizedLabel, args: parsedRecord }); - return; - } - - if (!parsedRecord) return; - const rawName = readRecoveredCallName(parsedRecord); - const args = asRecord(parsedRecord?.arguments ?? parsedRecord?.parameters ?? parsedRecord?.args) ?? null; - if (rawName.length > 0 && args) { - calls.push({ toolName: rawName, args }); - } -} - -function parseRecoveredCoordinatorToolCallsFromLabels(assistantText: string): RecoveredCoordinatorToolCall[] { - const calls: RecoveredCoordinatorToolCall[] = []; - const labelPattern = /^[ \t]*\/\/\s*([A-Za-z0-9_.:-]+)\s*$/gm; - let match: RegExpExecArray | null; - while ((match = labelPattern.exec(assistantText)) !== null) { - const label = match[1]?.trim() ?? ""; - if (!isRecoveredCoordinatorToolLabel(label)) continue; - const start = findJsonValueStart(assistantText, labelPattern.lastIndex); - if (start < 0) continue; - const extracted = extractBalancedJsonValue(assistantText, start); - if (!extracted) continue; - try { - pushRecoveredParsedCall(calls, label, JSON.parse(extracted.raw)); - labelPattern.lastIndex = extracted.endIndex; - } catch { - continue; - } - } - return calls; -} - -function parseRecoveredCoordinatorToolCallsFromFences(assistantText: string): RecoveredCoordinatorToolCall[] { - const calls: RecoveredCoordinatorToolCall[] = []; - const fencePattern = /```(?:json|js|javascript)?\s*\n([\s\S]*?)```/gi; - let match: RegExpExecArray | null; - while ((match = fencePattern.exec(assistantText)) !== null) { - const body = match[1]?.trim() ?? ""; - if (!body.length) continue; - const label = body - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.startsWith("//")) - ?.replace(/^\/\/\s*/, "") - .trim() ?? ""; - let parsed: unknown; - try { - parsed = parsePseudoToolJson(body); - } catch { - continue; - } - pushRecoveredParsedCall(calls, label, parsed); - } - return calls; -} - -function parseRecoveredCoordinatorToolCalls(assistantText: string): RecoveredCoordinatorToolCall[] { - const calls = parseRecoveredCoordinatorToolCallsFromLabels(assistantText); - if (calls.length > 0) return calls; - return parseRecoveredCoordinatorToolCallsFromFences(assistantText); -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const BATCH_DELAY_MS = 200; -const MAX_TOOL_STEPS_PER_TURN = 25; -const COMPACTION_THRESHOLD_RATIO = 0.50; -const MAX_CONVERSATION_HISTORY = 200; -const MAX_EVENT_RETRY_COUNT = 2; -const CHECKPOINT_TURN_INTERVAL = 5; -const CHECKPOINT_SUMMARY_MAX_CHARS = 8_000; -const COORDINATOR_TURN_TIMEOUT_MS = 120_000; -const COORDINATOR_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; -const PLANNING_STARTUP_RETRY_LIMIT = 1; -const POST_PLANNING_PHASE_TRANSITION_RETRY_LIMIT = 1; -const PHASE_TRANSITION_CORRECTION_LIMIT = 3; -const PLANNER_LAUNCH_TRACKER_STEP_KEY = "planner-launch-tracker"; -const CHAT_RUNTIME_TOOL_TRANSPORT = [ - "## ADE Mission-Control Tool Transport", - "This coordinator runs in the same ADE Work chat runtime stack as normal chats. Provider-native tools are not the mission-control API.", - "To call ADE mission-control tools, emit fenced JSON. ADE will replay those calls through real mission-control tools and enforce lane, phase, budget, and policy rules.", - "When a recovery note says to call a mission-control tool, do not report that tools are unavailable. Emit the fenced JSON call instead; ADE executes it after your turn.", - "Single tool call format:", - "```json", - "// tool_name", - "{\"argName\":\"value\"}", - "```", - "Parallel tool call format:", - "```json", - "// multi_tool_use.parallel", - "[{\"name\":\"tool_name\",\"arguments\":{\"argName\":\"value\"}}]", - "```", - "Use tool names from the Tool Quick Reference below, with or without an ade_ prefix. Keep surrounding prose minimal; when action is required, emit the tool-call block.", -].join("\n"); -const COORDINATOR_PLAN_TOOL_SELECTION: Record = { - // Force planning startup through ADE mission-control tools. OpenCode's - // native tools bypass the mission graph and planner delegation contract. - task: false, - glob: false, - grep: false, - codesearch: false, - code_search: false, - filesearch: false, - file_search: false, - websearch: false, - web_search: false, - read: false, - list: false, - write: false, - edit: false, - patch: false, - bash: false, - todowrite: false, - webfetch: false, - skill: false, - skills: false, - run_ade_action: false, - ade_run_ade_action: false, - list_ade_actions: false, - ade_list_ade_actions: false, - spawn_agent: false, - ade_spawn_agent: false, -}; -const CHECKPOINT_DAG_MUTATION_TOOLS = new Set([ - "spawn_worker", - "revise_plan", - "mark_step_complete", - "complete_mission", -]); -const RECOVERED_STATUS_PROBE_TOOLS = new Set([ - "read_mission_status", - "read_mission_state", - "list_tasks", - "list_workers", - "get_budget_status", - "check_finalization_status", -]); -const RECOVERED_IDLE_WHILE_WORKERS_RUNNING_TOOLS = new Set([ - ...RECOVERED_STATUS_PROBE_TOOLS, - "broadcast", - "get_worker_output", - "message_worker", - "send_message", - "update_mission_state", -]); -const RECOVERED_TOOL_RESULT_MAX_CHARS = 1400; -const RECOVERED_TOOL_RESULTS_MAX_CHARS = 5600; -const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "canceled"]); -const TERMINAL_PLANNING_TRACKER_STATUSES = new Set([ - "succeeded", - "failed", - "blocked", - "skipped", - "superseded", - "canceled", -]); - -// --------------------------------------------------------------------------- -// Worker Identity Prompt Builder -// --------------------------------------------------------------------------- - -export type WorkerIdentity = { - name: string; - role: string; - provider: string; - parentName: string; - missionSummary: string; - taskPrompt: string; - isSubOrchestrator?: boolean; - inheritedRules?: string; -}; - -export function buildWorkerIdentityPrompt(identity: WorkerIdentity): string { - const capabilities = identity.isSubOrchestrator - ? `- You are a ${identity.provider} agent with full coding capabilities -- You can read, write, and edit files -- You can run terminal commands -- You can search the codebase -- You can spawn your own workers using the same tools as your parent orchestrator` - : `- You are a ${identity.provider} agent with full coding capabilities -- You can read, write, and edit files -- You can run terminal commands -- You can search the codebase`; - - return `You are a worker agent spawned by the ADE orchestrator. - -## Your Identity -- Name: ${identity.name} -- Role: ${identity.role} -- Spawned by: ${identity.parentName} -- Mission: ${identity.missionSummary} - -## Your Task -${identity.taskPrompt} - -Think independently about how best to accomplish this. Make autonomous decisions about implementation approach. You own the outcome — don't wait for permission or guidance. - -## What You Can Do -${capabilities} -- You have full authority to make implementation decisions. Act decisively — don't hesitate or ask for approval on implementation details. - -## Communication -- Your orchestrator can send you messages during execution — read and follow them -- Complete the FULL scope of your task before finishing. Don't stop partway through. -- If you encounter problems, solve them yourself — only report truly blocking issues that require human input or architectural decisions. -- When you complete your task, provide a clear summary of what you did, what files changed, and any issues encountered. - -## Reflection Logging -- If the \`reflection_add\` tool is available, log structured reflections during execution: - - when blocked/frustrated - - when you discover a reusable pattern - - before reporting final completion -- Keep entries concrete and actionable (clear observation + recommendation + context). -- Use current run/step/attempt scope (caller-context fallback is supported). -${identity.inheritedRules ? `\n## Rules\n${identity.inheritedRules}` : ""}`; -} - -function formatStreamError(error: unknown): string { - if (typeof error === "string" && error.trim().length > 0) return error; - if (error instanceof Error && error.message.trim().length > 0) return error.message; - try { - return JSON.stringify(error); - } catch { - return String(error ?? "unknown error"); - } -} - -function looksLikeProviderAuthOrAccessFailure(message: string): boolean { - const normalized = message.trim().toLowerCase(); - if (!normalized.length) return false; - return ( - normalized.includes("does not have access to") - || normalized.includes("please login again") - || normalized.includes("please log in again") - || normalized.includes("please sign in again") - || normalized.includes("contact your administrator") - || normalized.includes("oauth token has been revoked") - || normalized.includes("obtain a new token") - || normalized.includes("permission_error") - || normalized.includes("loadapikeyerror") - || normalized.includes("token revoked") - ); -} - -function looksLikeProviderFailureReply(message: string): boolean { - const normalized = message.trim().toLowerCase(); - if (!normalized.length || normalized.length > 500) return false; - if (looksLikeProviderAuthOrAccessFailure(message)) return true; - return ( - normalized.includes("rate limit") - || normalized.includes("try again later") - || normalized.includes("temporarily unavailable") - || normalized.includes("service unavailable") - || normalized.includes("model is overloaded") - || normalized.includes("quota exceeded") - || normalized.includes("request failed with status code 429") - || normalized.includes("request failed with status code 503") - || normalized.includes("request failed with status code 500") - || normalized.includes("request failed with status code 403") - || normalized.includes("request failed with status code 401") - || normalized.includes("unauthorized") - || normalized.includes("forbidden") - || normalized.includes("invalid api key") - || normalized.includes("authentication failed") - || normalized.includes("model not found") - || normalized.includes("model is not supported") - || normalized.includes("unsupported model") - || normalized.includes("unknown model") - ); -} - -function classifyCoordinatorRuntimeFailure(error: unknown): Omit { - const message = formatStreamError(error).trim(); - const normalized = message.toLowerCase(); - - if (looksLikeProviderAuthOrAccessFailure(message)) { - return { - category: "provider_unreachable", - reasonCode: "coordinator_runtime_provider_auth_failed", - interventionType: "provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - title: "Coordinator could not authenticate with the selected provider", - body: `ADE paused the run because the selected provider rejected the coordinator credentials. Error: ${message || "No additional detail was provided."}`, - requestedAction: "Reconnect or sign in to the selected provider again, then resume the run. If that still fails, switch the mission to a different model before retrying.", - }; - } - - if ( - normalized.includes("model not found") - || normalized.includes("model is not supported") - || normalized.includes("unsupported model") - || normalized.includes("unknown model") - ) { - return { - category: "provider_unreachable", - reasonCode: "coordinator_runtime_model_unavailable", - interventionType: "provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - title: "Coordinator model is unavailable", - body: `ADE paused the run because the selected coordinator model is unavailable in the active runtime. Error: ${message || "No additional detail was provided."}`, - requestedAction: "Switch the coordinator to an available model, then resume the run. Retry the same model only after confirming the provider exposes it.", - }; - } - - if ( - normalized.includes("requires approval") - || normalized.includes("permission denied") - || normalized.includes("policy block") - || normalized.includes("not allowed") - || normalized.includes("approval denied") - || normalized.includes("eacces") - || normalized.includes("eperm") - ) { - return { - category: "permission_denied", - reasonCode: "coordinator_runtime_permission_denied", - interventionType: "policy_block", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - title: "Coordinator was blocked by permissions", - body: `ADE paused the run because the coordinator hit a permission or policy block. Error: ${message || "No additional detail was provided."}`, - requestedAction: "Adjust the permission or tool policy, then resume the run to retry the same coordinator.", - }; - } - - if ( - normalized.includes("rate limit") - || normalized.includes("timed out") - || normalized.includes("timeout") - || normalized.includes("temporarily unavailable") - || normalized.includes("connection refused") - || normalized.includes("network") - || normalized.includes("provider") - || normalized.includes("api key") - || normalized.includes("authentication") - || normalized.includes("unauthorized") - ) { - return { - category: "provider_unreachable", - reasonCode: "coordinator_runtime_provider_unreachable", - interventionType: "provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - title: "Coordinator lost contact with the selected provider", - body: `ADE paused the run because the coordinator could not keep talking to the selected provider. Error: ${message || "No additional detail was provided."}`, - requestedAction: "Check provider health, then resume the run to retry the same provider. If the provider remains unhealthy, switch the mission to a different model before retrying.", - }; - } - - if ( - /\b(?:codex|claude) cli exited with code\b/i.test(message) - || /\bexited with code\b/i.test(message) - || normalized.includes("session ended unexpectedly") - || normalized.includes("process exited") - ) { - return { - category: "cli_runtime_failure", - reasonCode: "coordinator_runtime_cli_exit", - interventionType: "unrecoverable_error", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - title: "Coordinator runtime exited unexpectedly", - body: `ADE paused the run because the coordinator process exited during execution. Error: ${message || "No additional detail was provided."}`, - requestedAction: "Inspect coordinator runtime health, then resume the run to retry the same provider and mission state.", - }; - } - - return { - category: "unknown", - reasonCode: "coordinator_runtime_failed", - interventionType: "unrecoverable_error", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - title: "Coordinator stopped unexpectedly", - body: `ADE paused the run because the coordinator stopped unexpectedly. Error: ${message || "No additional detail was provided."}`, - requestedAction: "Inspect the coordinator failure, then resume the run if you want to retry from this state.", - }; -} - -class CoordinatorFatalError extends Error { - readonly nonRetryable = true; - readonly runtimeFailure: CoordinatorRuntimeFailure | null; - - constructor(message: string, runtimeFailure?: CoordinatorRuntimeFailure | null) { - super(message); - this.runtimeFailure = runtimeFailure ?? null; - } -} - -// --------------------------------------------------------------------------- -// Coordinator Agent -// --------------------------------------------------------------------------- - -export class CoordinatorAgent { - private deps: CoordinatorAgentDeps; - private eventQueue: QueuedEvent[] = []; - private processing = false; - private batchTimer: ReturnType | null = null; - private dead = false; - private turnCount = 0; - private tools: Record; - private conversationHistory: ModelMessage[] = []; - private systemPrompt: string; - private compactionMonitor: CompactionMonitor | null = null; - private compactionCount = 0; - private lastEventTimestampMs: number | null = null; - private activeAbortController: AbortController | null = null; - private openCodeHandle: OpenCodeSessionHandle | null = null; - private openCodeIdleTimer: ReturnType | null = null; - private chatCoordinatorSessionId: string | null = null; - private planningStartupState: PlanningStartupState = "inactive"; - private planningStartupRetryCount = 0; - private phaseTransitionCorrectionCounts = new Map(); - private plannerLaunchTrackerStepId: string | null = null; - private pendingPlannerDelegationContract: DelegationContract | null = null; - - constructor(deps: CoordinatorAgentDeps) { - this.deps = deps; - this.tools = createCoordinatorToolSet({ - orchestratorService: deps.orchestratorService, - runId: deps.runId, - missionId: deps.missionId, - logger: deps.logger, - db: deps.db, - projectRoot: deps.projectRoot, - workspaceRoot: deps.workspaceRoot, - missionService: deps.missionService, - projectId: deps.projectId, - getMissionBudgetStatus: deps.getMissionBudgetStatus, - onDagMutation: deps.onDagMutation, - onRunFinalize: deps.onRunFinalize, - onHardCapTriggered: deps.onHardCapTriggered, - onBudgetWarning: deps.onBudgetWarning, - sendWorkerMessageToSession: deps.sendWorkerMessageToSession, - missionLaneId: deps.missionLaneId, - provisionLane: deps.provisionLane, - }); - this.wrapDagMutationToolsWithCheckpoint(); - this.systemPrompt = this.buildSystemPrompt(); - if (this.isPlanningFirstPhaseRun()) { - this.pendingPlannerDelegationContract = createDelegationContract({ - contractId: randomUUID(), - runId: deps.runId, - workerIntent: "planner", - mode: "exclusive", - scope: createDelegationScope({ - kind: "phase", - key: "phase:planning", - label: "planning", - }), - phaseKey: "planning", - status: "launching", - launchState: "awaiting_context", - launchPolicy: { - maxLaunchAttempts: PLANNING_STARTUP_RETRY_LIMIT + 1, - }, - failurePolicy: { - retryLimit: PLANNING_STARTUP_RETRY_LIMIT, - escalation: "intervention", - }, - }); - } - this.syncPlanningStartupStateFromContracts(); - this.ensurePlannerLaunchTrackerStep(); - - // Initialize compaction monitor if enabled - if (deps.enableCompaction) { - const model = resolveModelDescriptor(deps.modelId); - if (model) { - this.compactionMonitor = createCompactionMonitor( - model, - COMPACTION_THRESHOLD_RATIO, - ); - } - } - } - - // ─── Public API ────────────────────────────────────────────────── - - /** - * Inject a runtime event into the coordinator. Events are batched - * within BATCH_DELAY_MS and processed sequentially. - */ - injectEvent( - event: OrchestratorRuntimeEvent, - formattedMessage?: string, - ): void { - if (this.dead) return; - // Guard: skip event injection if the run is paused - if (this.isRunPaused()) return; - // While planning is blocked on a clarification, ignore runtime polling/noise - // and wait for the user's answer to re-enter the coordinator loop. - if (this.hasOpenPlanningClarification()) return; - const receivedAt = Date.now(); - const message = - formattedMessage ?? formatRuntimeEvent(event).summary; - this.lastEventTimestampMs = receivedAt; - this.eventQueue.push({ message, receivedAt }); - this.touchOpenCodeCoordinatorSession(); - this.scheduleBatch(); - } - - /** - * Inject a raw text message (e.g. user steering, chat message). - */ - injectMessage(message: string): void { - if (this.dead) return; - const receivedAt = Date.now(); - this.lastEventTimestampMs = receivedAt; - this.eventQueue.push({ message, receivedAt }); - this.touchOpenCodeCoordinatorSession(); - this.scheduleBatch(); - } - - async replayRecoveredMissionControlText( - assistantText: string, - turnId: string, - ): Promise { - if (this.dead) return 0; - return await this.executeRecoveredCoordinatorToolCalls(assistantText, turnId); - } - - /** Stop the coordinator. No further events will be processed. */ - shutdown(): void { - this.dead = true; - this.eventQueue = []; - this.activeAbortController?.abort(); - this.activeAbortController = null; - this.releaseOpenCodeCoordinatorSession("shutdown"); - this.releaseChatCoordinatorSession("shutdown"); - if (this.batchTimer) { - clearTimeout(this.batchTimer); - this.batchTimer = null; - } - } - - private clearOpenCodeIdleTimer(): void { - if (!this.openCodeIdleTimer) return; - clearTimeout(this.openCodeIdleTimer); - this.openCodeIdleTimer = null; - } - - private releaseOpenCodeCoordinatorSession( - reason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "paused_run" | "project_close" | "budget_eviction" | "pool_compaction" | "shutdown", - ): void { - this.clearOpenCodeIdleTimer(); - const handle = this.openCodeHandle; - this.openCodeHandle = null; - if (!handle) return; - handle.setEvictionHandler(null); - handle.setBusy(false); - try { - handle.close(reason); - } catch { - // ignore shutdown failures - } - } - - private touchOpenCodeCoordinatorSession(): void { - if (!this.openCodeHandle) return; - this.openCodeHandle.touch(); - this.clearOpenCodeIdleTimer(); - this.openCodeIdleTimer = setTimeout(() => { - if (this.dead) return; - if (!this.openCodeHandle) return; - if (this.isRunPaused()) { - this.releaseOpenCodeCoordinatorSession("paused_run"); - return; - } - if (this.activeAbortController || this.processing || this.eventQueue.length > 0) { - this.touchOpenCodeCoordinatorSession(); - return; - } - this.releaseOpenCodeCoordinatorSession("idle_ttl"); - }, COORDINATOR_IDLE_SESSION_TTL_MS); - if (this.openCodeIdleTimer.unref) this.openCodeIdleTimer.unref(); - } - - get isAlive(): boolean { - return !this.dead; - } - - get turns(): number { - return this.turnCount; - } - - get historyLength(): number { - return this.conversationHistory.length; - } - - // ─── Pause Guard ───────────────────────────────────────────────── - - /** - * Check if the associated run is currently paused. - * Uses a lightweight DB query to avoid loading the full run graph. - */ - private isRunPaused(): boolean { - try { - const row = this.deps.db.get<{ status: string }>( - `SELECT status FROM orchestrator_runs WHERE id = ? AND project_id = ?`, - [this.deps.runId, this.deps.projectId], - ); - return row?.status === "paused" || TERMINAL_RUN_STATUSES.has(String(row?.status ?? "").trim().toLowerCase()); - } catch { - // If the run can't be queried, treat as not paused (let other guards handle it) - return false; - } - } - - // ─── Batch Scheduling ──────────────────────────────────────────── - - private scheduleBatch(): void { - if (this.batchTimer) return; // already scheduled - this.batchTimer = setTimeout(() => { - this.batchTimer = null; - void this.processBatch(); - }, BATCH_DELAY_MS); - } - - private async processBatch(): Promise { - if (this.processing || this.dead || this.eventQueue.length === 0) - return; - // Guard: do not process batches while the run is paused - if (this.isRunPaused()) return; - this.refreshPlanningStartupState(); - this.processing = true; - - // Take a snapshot of events before removing from queue - const events = this.eventQueue.splice(0); - - try { - const combinedMessage = events - .map((e) => e.message) - .join("\n---\n"); - - // Add to conversation history as a user message - this.conversationHistory.push({ - role: "user", - content: combinedMessage, - }); - - // Trim conversation history to prevent unbounded growth - // (compaction is the proper solution, but this is a safety net) - if (this.conversationHistory.length > MAX_CONVERSATION_HISTORY) { - const trimCount = - this.conversationHistory.length - MAX_CONVERSATION_HISTORY; - this.conversationHistory.splice(0, trimCount); - } - - // Call the AI with tools - await this.runTurn(); - this.turnCount++; - if (this.turnCount % CHECKPOINT_TURN_INTERVAL === 0) { - this.saveCheckpoint("turn_interval"); - } - - // Check if compaction is needed - if (this.compactionMonitor?.shouldCompact()) { - await this.compactHistory(); - } - } catch (err) { - this.deps.logger.warn("coordinator_agent.batch_failed", { - runId: this.deps.runId, - error: err instanceof Error ? err.message : String(err), - }); - - // Re-enqueue events that failed processing (with retry limit) - if (!(err instanceof CoordinatorFatalError)) { - for (const event of events) { - const retryCount = (event.retryCount ?? 0) + 1; - if (retryCount <= MAX_EVENT_RETRY_COUNT) { - this.eventQueue.push({ ...event, retryCount }); - } - // Events exceeding MAX_EVENT_RETRY_COUNT are dropped to prevent infinite loops - } - } - } finally { - this.processing = false; - // If more events arrived during processing, schedule another batch - if (this.eventQueue.length > 0 && !this.dead) { - this.scheduleBatch(); - } - } - } - - // ─── AI Turn Execution ─────────────────────────────────────────── - - private wrapDagMutationToolsWithCheckpoint(): void { - for (const toolName of CHECKPOINT_DAG_MUTATION_TOOLS) { - const tool = this.tools[toolName] as any; - if (!tool || typeof tool.execute !== "function") continue; - const originalExecute = tool.execute.bind(tool); - tool.execute = async (...args: any[]) => { - this.saveCheckpoint(`before_tool:${toolName}`); - return originalExecute(...args); - }; - } - } - - private usesOpenCodeCoordinatorRuntime(): boolean { - const descriptor = resolveModelDescriptor(this.deps.modelId); - return Boolean(descriptor && descriptor.family !== "cursor"); - } - - private async ensureOpenCodeCoordinatorSession(): Promise { - if (this.openCodeHandle) return this.openCodeHandle; - const descriptor = resolveModelDescriptor(this.deps.modelId); - if (!descriptor) { - throw new Error(`Coordinator model '${this.deps.modelId}' is not registered.`); - } - if (descriptor.family === "cursor") { - throw new Error("Cursor models are not supported for coordinator execution. Choose Claude, Codex, or OpenCode."); - } - const projectConfig = this.deps.projectConfigService?.get().effective ?? { ai: {} }; - // Discover loaded local models so OpenCode knows about them. - const discoveredLocalModels: DiscoveredLocalModelEntry[] = []; - const aiConfig = projectConfig.ai as { localProviders?: Record } | undefined; - const localProviders = aiConfig?.localProviders ?? {}; - for (const family of ["ollama", "lmstudio"] as const) { - if ((localProviders as Record)[family]?.enabled === false) continue; - const endpoint = (localProviders as Record)[family]?.endpoint - ?? getLocalProviderDefaultEndpoint(family); - try { - const inspection = await inspectLocalProvider(family, endpoint); - for (const m of inspection.loadedModels) { - discoveredLocalModels.push({ provider: m.provider, modelId: m.modelId }); - } - } catch { /* offline — non-fatal */ } - } - this.openCodeHandle = await startOpenCodeSession({ - directory: this.deps.workspaceRoot, - title: `ADE coordinator: ${this.deps.missionGoal}`, - projectConfig, - discoveredLocalModels, - ownerKind: "coordinator", - ownerId: this.deps.runId, - ownerKey: `coordinator:${this.deps.runId}`, - leaseKind: "shared", - logger: this.deps.logger, - }); - const registeredHandle = this.openCodeHandle; - registeredHandle.setEvictionHandler((reason) => { - if (this.openCodeHandle !== registeredHandle) { - return; - } - if (this.openCodeHandle) { - this.releaseOpenCodeCoordinatorSession( - reason === "error" || reason === "config_changed" || reason === "attach_failed" - ? "handle_close" - : reason, - ); - } - }); - registeredHandle.setBusy(false); - this.touchOpenCodeCoordinatorSession(); - return this.openCodeHandle; - } - - private async runOpenCodeTurn( - turnId: string, - abortController: AbortController, - ): Promise<{ - assistantText: string; - sawStreamPart: boolean; - streamedStepCount: number; - executedToolCount: number; - awaitingBlockingUserInput: boolean; - planningStartupAbortMode: "none" | "planner_started" | "retry" | "failed"; - planningStartupFailure: CoordinatorPlanningStartupFailure | null; - phaseTransitionRequired: PhaseTransitionRequiredSignal | null; - }> { - const descriptor = resolveModelDescriptor(this.deps.modelId); - if (!descriptor) { - throw new Error(`Coordinator model '${this.deps.modelId}' is not registered.`); - } - const handle = await this.ensureOpenCodeCoordinatorSession(); - handle.setBusy(true); - this.touchOpenCodeCoordinatorSession(); - const eventStream = await openCodeEventStream({ - client: handle.client, - directory: handle.directory, - signal: abortController.signal, - }); - - const latestUserMessage = [...this.conversationHistory] - .reverse() - .find((entry) => entry.role === "user"); - const basePromptText = typeof latestUserMessage?.content === "string" - ? latestUserMessage.content - : "Continue coordinating the mission."; - const promptText = this.buildPromptForTurn(basePromptText); - const toolSelection = await refreshOpenCodeSessionToolSelection(handle); - const coordinatorToolSelection = { - ...(toolSelection ?? {}), - ...COORDINATOR_PLAN_TOOL_SELECTION, - }; - - await handle.client.session.promptAsync({ - path: { id: handle.sessionId }, - query: { directory: handle.directory }, - body: { - agent: mapPermissionModeToOpenCodeAgent("plan"), - model: resolveOpenCodeModelSelection(descriptor), - tools: coordinatorToolSelection, - parts: buildOpenCodePromptParts({ - prompt: promptText, - system: this.systemPrompt, - }), - }, - }); - - let assistantText = ""; - let sawStreamPart = false; - let streamedStepCount = 0; - let executedToolCount = 0; - let awaitingBlockingUserInput = false; - let planningStartupAbortMode: "none" | "planner_started" | "retry" | "failed" = "none"; - let planningStartupFailure: CoordinatorPlanningStartupFailure | null = null; - let phaseTransitionRequired: PhaseTransitionRequiredSignal | null = null; - - for await (const event of eventStream) { - const sessionId = (() => { - switch (event.type) { - case "message.updated": - case "session.created": - case "session.updated": - case "session.deleted": - return event.properties.info.id; - case "message.part.updated": - return event.properties.part.sessionID; - case "message.part.removed": - case "permission.updated": - case "permission.replied": - case "session.status": - case "session.idle": - case "todo.updated": - case "session.diff": - case "command.executed": - return event.properties.sessionID; - case "session.error": - return event.properties.sessionID ?? null; - default: - return null; - } - })(); - if (sessionId !== handle.sessionId) continue; - - if (event.type === "message.part.updated") { - sawStreamPart = true; - const { part, delta } = event.properties; - if (part.type === "step-start") { - streamedStepCount += 1; - this.deps.onCoordinatorEvent?.({ type: "step_boundary", stepNumber: streamedStepCount, turnId }); - continue; - } - if (part.type === "text") { - if ((part as { synthetic?: boolean }).synthetic || (part as { ignored?: boolean }).ignored) { - continue; - } - const nextDelta = typeof delta === "string" ? delta : part.text; - if (nextDelta.length > 0) { - assistantText += nextDelta; - this.deps.onCoordinatorEvent?.({ type: "text", text: nextDelta, turnId, itemId: part.id }); - } - continue; - } - if (part.type === "reasoning") { - if ((part as { synthetic?: boolean }).synthetic || (part as { ignored?: boolean }).ignored) { - continue; - } - const nextDelta = typeof delta === "string" ? delta : part.text; - if (nextDelta.length > 0) { - this.deps.onCoordinatorEvent?.({ - type: "activity", - activity: "thinking", - detail: "Thinking through the next move", - turnId, - }); - this.deps.onCoordinatorEvent?.({ type: "reasoning", text: nextDelta, turnId, itemId: part.id }); - } - continue; - } - if (part.type === "tool") { - executedToolCount += 1; - const itemId = part.callID || part.id; - const normalizedToolName = normalizeCoordinatorToolName(part.tool); - const isCompletedStartupPlannerSpawn = - normalizedToolName === "spawn_worker" - && this.planningStartupState !== "inactive" - && part.state.status === "completed"; - if (!isCompletedStartupPlannerSpawn) { - this.handlePlanningStartupToolCall(part.tool); - } - this.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: part.tool, - args: part.state.input, - itemId, - turnId, - }); - if (part.state.status === "completed") { - const toolOutput = coerceToolOutputRecord(part.state.output); - const transitionSignal = extractPhaseTransitionRequiredSignal(toolOutput); - if (transitionSignal) { - phaseTransitionRequired = transitionSignal; - } - if ( - normalizedToolName === "set_current_phase" - && toolOutput?.ok === true - ) { - this.phaseTransitionCorrectionCounts.clear(); - } - this.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: part.tool, - result: part.state.output, - itemId, - turnId, - status: "completed", - }); - if (toolOutput?.awaitingUserResponse === true && toolOutput?.blocking !== false) { - awaitingBlockingUserInput = true; - abortController.abort(); - } - if (normalizedToolName === "spawn_worker" && this.planningStartupState !== "inactive") { - if (toolOutput?.ok === true) { - const delegatedContract = extractDelegationContract(toolOutput?.delegationContract ?? null); - if (delegatedContract && delegatedContract.workerIntent === "planner") { - this.pendingPlannerDelegationContract = delegatedContract; - } else if (this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: - typeof toolOutput?.workerId === "string" && toolOutput.workerId.trim().length > 0 - ? [toolOutput.workerId.trim()] - : this.pendingPlannerDelegationContract.activeWorkerIds, - }); - } - this.planningStartupState = "waiting_on_planner"; - if (this.pendingPlannerDelegationContract) { - this.emitDelegationState( - this.pendingPlannerDelegationContract, - toolOutput?.launched === false - ? "The planning agent is queued. I’m waiting for it to start." - : "The planning agent is running. I’m waiting for its result.", - ); - } - this.updatePlannerLaunchTrackerStep({ - status: "running", - reason: toolOutput?.launched === false ? "planner_queued" : "planner_launched", - detail: { - launched: toolOutput?.launched ?? null, - launchNote: typeof toolOutput?.launchNote === "string" ? toolOutput.launchNote : null, - stepId: typeof toolOutput?.stepId === "string" ? toolOutput.stepId : null, - }, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "started", - turnId, - message: toolOutput?.launched === false - ? "The planning agent is queued. I’m waiting for it to start." - : "The planning agent is running. I’m waiting for its result.", - }); - planningStartupAbortMode = "planner_started"; - abortController.abort(); - } else { - const failure = this.buildPlannerLaunchFailure( - typeof toolOutput?.error === "string" ? toolOutput.error : `Tool '${part.tool}' failed to launch the planner.`, - part.tool, - ); - planningStartupFailure = failure; - if (failure.retryable && this.planningStartupRetryCount < PLANNING_STARTUP_RETRY_LIMIT) { - this.schedulePlanningStartupRetry(failure); - planningStartupAbortMode = "retry"; - } else { - this.handlePlanningStartupFailure(failure); - planningStartupAbortMode = "failed"; - } - abortController.abort(); - } - } - } else if (part.state.status === "error") { - if (normalizedToolName === "spawn_worker" && this.planningStartupState !== "inactive") { - const failure = this.buildPlannerLaunchFailure(String(part.state.error ?? "tool failed"), part.tool); - planningStartupFailure = failure; - if (failure.retryable && this.planningStartupRetryCount < PLANNING_STARTUP_RETRY_LIMIT) { - this.schedulePlanningStartupRetry(failure); - planningStartupAbortMode = "retry"; - } else { - this.handlePlanningStartupFailure(failure); - planningStartupAbortMode = "failed"; - } - abortController.abort(); - } - this.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: part.tool, - result: { error: part.state.error }, - itemId, - turnId, - status: "failed", - }); - this.deps.onCoordinatorEvent?.({ - type: "error", - message: `Tool '${part.tool}' failed: ${part.state.error}`, - itemId, - turnId, - }); - } - continue; - } - } - - if (event.type === "todo.updated") { - this.deps.onCoordinatorEvent?.({ - type: "todo_update", - items: event.properties.todos.map((todo: { id: string; content: string; status: string }) => ({ - id: todo.id, - description: todo.content, - status: todo.status === "completed" - ? "completed" - : todo.status === "in_progress" - ? "in_progress" - : "pending", - })), - turnId, - }); - continue; - } - - if (event.type === "permission.updated") { - throw new Error(`Coordinator OpenCode session requested permission '${event.properties.type}'.`); - } - - if (event.type === "session.error") { - throw new Error(String(event.properties.error?.data?.message ?? "OpenCode coordinator turn failed.")); - } - - if (event.type === "session.idle") { - break; - } - } - - return { - assistantText: assistantText.trim(), - sawStreamPart, - streamedStepCount, - executedToolCount, - awaitingBlockingUserInput, - planningStartupAbortMode, - planningStartupFailure, - phaseTransitionRequired, - }; - } - - private releaseChatCoordinatorSession(reason: "shutdown" | "ended_session" | "model_switch" | "project_close" | "budget_eviction"): void { - const sessionId = this.chatCoordinatorSessionId; - this.chatCoordinatorSessionId = null; - if (!sessionId || !this.deps.agentChatService) return; - void this.deps.agentChatService.dispose({ sessionId }).catch((error) => { - this.deps.logger.debug("coordinator_agent.chat_session_dispose_failed", { - runId: this.deps.runId, - sessionId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - } - - private async ensureChatCoordinatorSession(): Promise { - if (this.chatCoordinatorSessionId) return this.chatCoordinatorSessionId; - const agentChatService = this.deps.agentChatService; - if (!agentChatService) { - throw new Error("Agent chat service is not available for mission coordinator runtime."); - } - const descriptor = resolveModelDescriptor(this.deps.modelId); - if (!descriptor) { - throw new Error(`Coordinator model '${this.deps.modelId}' is not registered.`); - } - const laneId = this.deps.missionLaneId?.trim(); - if (!laneId) { - throw new Error("Mission coordinator chat runtime requires a mission lane before startup."); - } - const provider = resolveProviderGroupForModel(descriptor); - const session = await agentChatService.createSession({ - laneId, - provider, - model: descriptor.isCliWrapped ? descriptor.providerModelId : descriptor.id, - modelId: descriptor.id as any, - title: "Mission coordinator", - sessionProfile: "workflow", - surface: "mission", - reasoningEffort: this.deps.coordinatorReasoningEffort ?? null, - permissionMode: "plan", - ...(provider === "claude" ? { interactionMode: "plan" as const } : {}), - }); - this.chatCoordinatorSessionId = session.id; - this.deps.onCoordinatorChatSessionStarted?.({ - sessionId: session.id, - missionId: this.deps.missionId, - runId: this.deps.runId, - laneId, - }); - this.deps.logger.info("coordinator_agent.chat_session_started", { - runId: this.deps.runId, - sessionId: session.id, - modelId: descriptor.id, - provider, - laneId, - }); - return session.id; - } - - private buildChatRuntimePrompt(promptText: string): string { - return [ - this.turnCount === 0 ? this.systemPrompt : null, - CHAT_RUNTIME_TOOL_TRANSPORT, - "## Current Coordinator Input", - promptText, - ].filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).join("\n\n"); - } - - private async runChatRuntimeTurn( - _turnId: string, - abortController: AbortController, - ): Promise<{ - assistantText: string; - sawStreamPart: boolean; - streamedStepCount: number; - executedToolCount: number; - awaitingBlockingUserInput: boolean; - planningStartupAbortMode: "none" | "planner_started" | "retry" | "failed"; - planningStartupFailure: CoordinatorPlanningStartupFailure | null; - phaseTransitionRequired: PhaseTransitionRequiredSignal | null; - }> { - const agentChatService = this.deps.agentChatService; - if (!agentChatService) { - throw new Error("Agent chat service is not available for mission coordinator runtime."); - } - const sessionId = await this.ensureChatCoordinatorSession(); - const latestUserMessage = [...this.conversationHistory] - .reverse() - .find((entry) => entry.role === "user"); - const basePromptText = typeof latestUserMessage?.content === "string" - ? latestUserMessage.content - : "Continue coordinating the mission."; - const promptText = this.buildChatRuntimePrompt(this.buildPromptForTurn(basePromptText)); - const abortListener = () => { - void agentChatService.interrupt({ sessionId }).catch(() => undefined); - }; - abortController.signal.addEventListener("abort", abortListener, { once: true }); - const laneId = this.deps.missionLaneId?.trim() || null; - this.deps.onCoordinatorChatRuntimeTurnStarted?.({ - sessionId, - missionId: this.deps.missionId, - runId: this.deps.runId, - laneId, - }); - try { - const result = await agentChatService.runSessionTurn({ - sessionId, - text: promptText, - displayText: this.turnCount === 0 - ? "ADE coordinator start: initialize the mission." - : "ADE coordinator tick: review mission state and route the next action.", - timeoutMs: COORDINATOR_TURN_TIMEOUT_MS, - ...(this.deps.coordinatorReasoningEffort ? { reasoningEffort: this.deps.coordinatorReasoningEffort } : {}), - }); - const assistantText = result.outputText.trim(); - return { - assistantText, - sawStreamPart: assistantText.length > 0, - streamedStepCount: assistantText.length > 0 ? 1 : 0, - executedToolCount: 0, - awaitingBlockingUserInput: false, - planningStartupAbortMode: "none", - planningStartupFailure: null, - phaseTransitionRequired: null, - }; - } finally { - abortController.signal.removeEventListener("abort", abortListener); - this.deps.onCoordinatorChatRuntimeTurnFinished?.({ - sessionId, - missionId: this.deps.missionId, - runId: this.deps.runId, - laneId, - }); - } - } - - private buildPromptForTurn(basePromptText: string): string { - if ( - this.turnCount !== 0 || - !this.isPlanningFirstPhaseRun() || - this.hasPlanningExecutionRecord() || - this.hasOpenPlanningClarification() - ) { - return basePromptText; - } - - const planningPhase = [...(this.deps.phases ?? [])] - .sort((a, b) => a.position - b.position) - .find((phase) => phase.phaseKey.trim().toLowerCase() === "planning") ?? null; - const modelId = planningPhase?.model?.modelId?.trim(); - const modelInstruction = modelId - ? `Use modelId "${modelId}" for that planning worker.` - : "Use the configured planning phase model for that planning worker."; - return [ - "PLANNING STARTUP REQUIRED:", - "Your next response must use ADE mission-control tools. Do not answer with text only.", - "Call get_project_context first, then call spawn_worker exactly once to launch a read-only planning worker.", - "Use the mission-control tool names directly or with an ade_ prefix, such as get_project_context and spawn_worker.", - `${modelInstruction} The worker prompt must include the full mission goal, the planning phase instructions, and clear instructions to discover the plan, avoid file edits, and return the plan through report_result.`, - "Do not call native OpenCode repo/search/shell/web/planner tools before the planner is spawned.", - "", - "Mission message:", - basePromptText, - ].join("\n"); - } - - private summarizeForCheckpoint(): string { - const recentHistory = this.conversationHistory.slice(-10); - const lines: string[] = []; - for (const message of recentHistory) { - const role = String(message.role ?? "unknown").trim() || "unknown"; - const rawContent = - typeof message.content === "string" ? message.content : JSON.stringify(message.content); - const normalized = rawContent.replace(/\s+/g, " ").trim(); - if (!normalized.length) continue; - lines.push(`${role}: ${normalized.slice(0, 500)}`); - } - if (this.eventQueue.length > 0) { - lines.push(`pending_events: ${this.eventQueue.length}`); - } - const summary = lines.join("\n").trim(); - if (!summary.length) { - return "No conversation history yet."; - } - return summary.length > CHECKPOINT_SUMMARY_MAX_CHARS - ? `${summary.slice(0, CHECKPOINT_SUMMARY_MAX_CHARS)}\n[checkpoint summary truncated]` - : summary; - } - - private saveCheckpoint(trigger: string): void { - const lastEventTimestamp = - Number.isFinite(this.lastEventTimestampMs) && this.lastEventTimestampMs != null - ? new Date(this.lastEventTimestampMs).toISOString() - : null; - const checkpoint = { - version: 1, - runId: this.deps.runId, - missionId: this.deps.missionId, - conversationSummary: this.summarizeForCheckpoint(), - lastEventTimestamp, - turnCount: this.turnCount, - compactionCount: this.compactionCount, - savedAt: new Date().toISOString(), - }; - void writeCoordinatorCheckpoint(this.deps.projectRoot, this.deps.runId, checkpoint).catch((error) => { - this.deps.logger.debug("coordinator_agent.checkpoint_write_failed", { - runId: this.deps.runId, - trigger, - error: error instanceof Error ? error.message : String(error), - }); - }); - } - - private isPlanningExecutionStep(step: OrchestratorStep | null | undefined): boolean { - if (!step) return false; - const metadata = asRecord(step.metadata); - if (metadata?.isTask === true || metadata?.displayOnlyTask === true || metadata?.plannerLaunchTracker === true) { - return false; - } - const phaseKey = typeof metadata?.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - const phaseName = typeof metadata?.phaseName === "string" ? metadata.phaseName.trim().toLowerCase() : ""; - const stepType = typeof metadata?.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - return phaseKey === "planning" || phaseName === "planning" || stepType === "planning"; - } - - private resolvePlannerLaunchTrackerStep(): OrchestratorStep | null { - if (!this.isPlanningFirstPhaseRun()) return null; - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - const step = graph.steps.find((candidate) => { - if (this.plannerLaunchTrackerStepId && candidate.id === this.plannerLaunchTrackerStepId) return true; - const metadata = asRecord(candidate.metadata); - return metadata?.plannerLaunchTracker === true || candidate.stepKey === PLANNER_LAUNCH_TRACKER_STEP_KEY; - }) ?? null; - if (step?.id) this.plannerLaunchTrackerStepId = step.id; - return step; - } catch { - return null; - } - } - - private ensurePlannerLaunchTrackerStep(): void { - if (!this.isPlanningFirstPhaseRun()) return; - const existing = this.resolvePlannerLaunchTrackerStep(); - if (existing) return; - try { - const created = this.deps.orchestratorService.addSteps({ - runId: this.deps.runId, - steps: [ - { - stepKey: PLANNER_LAUNCH_TRACKER_STEP_KEY, - title: "Launch planning worker", - stepIndex: -1_000, - dependencyStepKeys: [], - joinPolicy: "all_success", - laneId: this.deps.missionLaneId ?? null, - executorKind: "manual", - metadata: { - isTask: true, - displayOnlyTask: true, - plannerLaunchTracker: true, - systemManaged: true, - phaseKey: "planning", - phaseName: "Planning", - stepType: "planner_launch", - plannerLaunchState: "pending", - }, - }, - ], - }); - this.plannerLaunchTrackerStepId = created[0]?.id ?? null; - } catch (error) { - this.deps.logger.debug("coordinator_agent.planner_launch_tracker_create_failed", { - runId: this.deps.runId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private updatePlannerLaunchTrackerStep(args: { - status: OrchestratorStepStatus; - reason: string; - detail?: Record | null; - }): void { - const step = this.resolvePlannerLaunchTrackerStep(); - if (!step) return; - const existingMetadata = asRecord(step.metadata) ?? {}; - const existingState = typeof existingMetadata.plannerLaunchState === "string" ? existingMetadata.plannerLaunchState.trim() : ""; - const existingReason = typeof existingMetadata.plannerLaunchReason === "string" ? existingMetadata.plannerLaunchReason.trim() : ""; - if (step.status === args.status && existingState === args.status && existingReason === args.reason && !args.detail) { - return; - } - const now = new Date().toISOString(); - const metadata = { - ...existingMetadata, - plannerLaunchTracker: true, - plannerLaunchState: args.status, - plannerLaunchReason: args.reason, - plannerLaunchUpdatedAt: now, - ...(args.detail ? { plannerLaunchDetail: args.detail } : {}), - }; - const startedAt = args.status === "pending" ? step.startedAt ?? null : (step.startedAt ?? now); - const completedAt = TERMINAL_PLANNING_TRACKER_STATUSES.has(args.status) ? now : null; - try { - this.deps.db.run( - ` - update orchestrator_steps - set status = ?, - metadata_json = ?, - updated_at = ?, - started_at = ?, - completed_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [ - args.status, - JSON.stringify(metadata), - now, - startedAt, - completedAt, - step.id, - this.deps.runId, - this.deps.projectId, - ], - ); - this.deps.orchestratorService.appendTimelineEvent({ - runId: this.deps.runId, - stepId: step.id, - eventType: "planner_launch_status", - reason: args.reason, - detail: { - plannerLaunchState: args.status, - ...(args.detail ?? {}), - }, - }); - this.deps.orchestratorService.emitRuntimeUpdate({ - runId: this.deps.runId, - stepId: step.id, - reason: `planner_launch_${args.status}`, - }); - } catch (error) { - this.deps.logger.debug("coordinator_agent.planner_launch_tracker_update_failed", { - runId: this.deps.runId, - stepId: step.id, - status: args.status, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private refreshPlanningStartupState(): void { - this.syncPlanningStartupStateFromContracts(); - } - - private isPlanningStartupGuardActive(): boolean { - this.syncPlanningStartupStateFromContracts(); - return this.planningStartupState !== "inactive" && this.planningStartupState !== "failed"; - } - - private listActiveDelegationContracts(): DelegationContract[] { - let graphContracts: DelegationContract[] = []; - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - graphContracts = extractActiveDelegationContracts(graph); - const planningSteps = filterExecutionSteps(graph.steps).filter((step) => this.isPlanningExecutionStep(step)); - if (planningSteps.some((step) => step.status === "succeeded")) { - this.pendingPlannerDelegationContract = null; - } else if (planningSteps.some((step) => step.status === "failed" || step.status === "blocked" || step.status === "canceled")) { - const pendingContract = this.pendingPlannerDelegationContract; - if (pendingContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(pendingContract, { - status: "blocked", - launchState: "blocked", - completedAt: new Date().toISOString(), - }); - } - } - } catch { - // Best-effort graph inspection only. - } - const allContracts = [...graphContracts]; - if (this.pendingPlannerDelegationContract) { - const hasMatchingPlannerContract = graphContracts.some((contract) => - contract.workerIntent === "planner" && contract.scope.key === this.pendingPlannerDelegationContract?.scope.key, - ); - if (!hasMatchingPlannerContract) { - allContracts.push(this.pendingPlannerDelegationContract); - } - } - return allContracts; - } - - private emitDelegationState(contract: DelegationContract, message?: string): void { - this.deps.onCoordinatorEvent?.({ - type: "delegation_state", - contract, - message, - }); - } - - private syncPlanningStartupStateFromContracts(): void { - if (!this.isPlanningFirstPhaseRun()) { - this.planningStartupState = "inactive"; - this.pendingPlannerDelegationContract = null; - return; - } - const plannerContract = - this.listActiveDelegationContracts().find((contract) => contract.workerIntent === "planner" && contract.mode === "exclusive") - ?? this.pendingPlannerDelegationContract; - const derived = derivePlanningStartupStateFromContract(plannerContract ?? null); - this.planningStartupState = derived.state; - this.pendingPlannerDelegationContract = derived.contract; - if (derived.state === "inactive") { - this.updatePlannerLaunchTrackerStep({ - status: "succeeded", - reason: "planner_ready", - }); - } - } - - private queueInternalMessage(message: string): void { - if (this.dead) return; - const receivedAt = Date.now(); - this.lastEventTimestampMs = receivedAt; - this.eventQueue.push({ message, receivedAt }); - } - - private queuePhaseTransitionCorrection(signal: PhaseTransitionRequiredSignal, turnId: string): void { - const phaseKey = signal.phaseKey.trim(); - if (!phaseKey.length) return; - const nextCount = (this.phaseTransitionCorrectionCounts.get(phaseKey) ?? 0) + 1; - this.phaseTransitionCorrectionCounts.set(phaseKey, nextCount); - - if (nextCount > PHASE_TRANSITION_CORRECTION_LIMIT) { - throw new CoordinatorFatalError( - `Coordinator repeatedly attempted work before entering phase '${phaseKey}'.`, - { - category: "unknown", - reasonCode: "phase_transition_required_not_followed", - interventionType: "policy_block", - retryable: true, - recoveryOptions: ["retry", "cancel_run"], - message: `The coordinator was told ${PHASE_TRANSITION_CORRECTION_LIMIT} times to call set_current_phase for '${phaseKey}' before spawning more workers, but did not complete the transition.`, - title: "Coordinator did not enter the required phase", - body: - `ADE blocked work because the coordinator repeatedly tried to continue before entering the required '${phaseKey}' phase. ` + - "The run was paused instead of letting phase ordering drift.", - requestedAction: "Retry the coordinator turn after inspecting phase configuration, or cancel the run.", - turnId, - }, - ); - } - - this.deps.logger.info("coordinator_agent.phase_transition_correction_queued", { - runId: this.deps.runId, - phaseKey, - reason: signal.reason, - attempt: nextCount, - }); - this.queueInternalMessage([ - "RUNTIME CORRECTION:", - `The run must enter phase "${phaseKey}" before more work can be created or spawned.`, - `Your next action must be to call set_current_phase with phaseKey "${phaseKey}" and reason "${signal.reason}".`, - "After that tool succeeds, continue with the implementation DAG/workers. Do not retry spawn_worker or create_task before the phase transition succeeds.", - ].join("\n")); - } - - private buildNativeToolPlanningFailure(toolName: string): CoordinatorPlanningStartupFailure { - return { - category: "native_tool_violation", - reasonCode: "planning_startup_native_tool_violation", - interventionType: "policy_block", - retryable: false, - recoveryOptions: ["cancel_run"], - message: `Coordinator tried to use '${toolName}' during planning startup.`, - title: "Planning startup left the safe tool lane", - body: - `During the planning startup phase, the coordinator attempted to call '${toolName}' instead of staying inside ADE's planning tools. ` + - "ADE stopped the turn and opened an explicit recovery path instead of allowing unbounded repo exploration.", - requestedAction: "Retry planning or cancel the run.", - toolName, - retryCount: this.planningStartupRetryCount, - }; - } - - private buildPlannerLaunchFailure(message: string, toolName?: string | null): CoordinatorPlanningStartupFailure { - const classification = classifyPlannerLaunchFailure(message); - const requestedAction = classification.category === "provider_unreachable" - ? "Retry the planner, switch to a fallback model if one is available, or cancel the run." - : classification.category === "permission_denied" - ? "Adjust the permission or tool policy, then retry the planner." - : classification.category === "run_context_bug" - ? "Cancel the run and fix the run-context wiring before retrying." - : "Retry the planner or cancel the run."; - const title = classification.category === "provider_unreachable" - ? "Planner launch is blocked by the model provider" - : classification.category === "permission_denied" - ? "Planner launch is blocked by tool permissions" - : classification.category === "run_context_bug" - ? "Planner launch lost its run context" - : classification.category === "tool_schema_error" - ? "Planner launch failed validation" - : "Planner launch failed"; - return { - ...classification, - message, - title, - body: - `ADE could not launch the planning worker during planning startup. ${message.trim() || "No additional error detail was provided."} ` + - "The coordinator did not fall back into planner-style repo exploration.", - requestedAction, - toolName: toolName ?? null, - retryCount: this.planningStartupRetryCount, - }; - } - - private buildCoordinatorRuntimeFailure(error: unknown, turnId: string): CoordinatorRuntimeFailure { - const message = formatStreamError(error).trim(); - const classification = classifyCoordinatorRuntimeFailure(message); - return { - ...classification, - message, - turnId, - body: `${classification.body} ADE did not auto-fail over to another provider or silently retry the same failed turn.`, - }; - } - - private buildAssistantReplyRuntimeFailure( - assistantText: string, - turnId: string, - streamedStepCount: number, - ): CoordinatorRuntimeFailure | null { - const message = assistantText.trim(); - if (!message.length) return null; - if (streamedStepCount > 0) return null; - if (!looksLikeProviderFailureReply(message)) return null; - return this.buildCoordinatorRuntimeFailure(message, turnId); - } - - private schedulePlanningStartupRetry(failure: CoordinatorPlanningStartupFailure): void { - this.planningStartupRetryCount += 1; - if (this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - status: "launching", - launchState: "awaiting_worker_launch", - failure: { - category: failure.category, - reasonCode: failure.reasonCode, - retryable: failure.retryable, - recoveryOptions: failure.recoveryOptions, - message: failure.message, - toolName: failure.toolName ?? null, - retryCount: this.planningStartupRetryCount, - occurredAt: new Date().toISOString(), - }, - }); - this.emitDelegationState( - this.pendingPlannerDelegationContract, - "The planner hit a launch issue, so I’m retrying once.", - ); - } - this.planningStartupState = "awaiting_planner_launch"; - this.updatePlannerLaunchTrackerStep({ - status: "pending", - reason: "planner_launch_retry_scheduled", - detail: { - category: failure.category, - message: failure.message, - retryCount: this.planningStartupRetryCount, - }, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - message: "The planner hit a launch issue, so I’m retrying once.", - }); - this.queueInternalMessage( - "Planner launch hit a transient provider issue. Retry spawning exactly one planning worker now. " + - "Do not inspect files, call native tools, or continue planning work yourself while retrying.", - ); - } - - private handlePlanningStartupFailure(failure: CoordinatorPlanningStartupFailure): void { - if (this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - status: failure.category === "permission_denied" || failure.category === "native_tool_violation" ? "blocked" : "launch_failed", - launchState: "blocked", - failure: { - category: failure.category, - reasonCode: failure.reasonCode, - retryable: failure.retryable, - recoveryOptions: failure.recoveryOptions, - message: failure.message, - toolName: failure.toolName ?? null, - retryCount: failure.retryCount, - occurredAt: new Date().toISOString(), - }, - completedAt: new Date().toISOString(), - }); - this.emitDelegationState( - this.pendingPlannerDelegationContract, - failure.category === "permission_denied" - ? "The planner was blocked by a permission issue, so I paused the run." - : failure.category === "provider_unreachable" - ? "The planner hit a launch issue, so I paused the run and opened recovery options." - : "The planner hit a launch issue, so I paused the run.", - ); - } - this.planningStartupState = "failed"; - this.updatePlannerLaunchTrackerStep({ - status: failure.category === "permission_denied" || failure.category === "native_tool_violation" ? "blocked" : "failed", - reason: failure.reasonCode, - detail: { - category: failure.category, - message: failure.message, - retryCount: failure.retryCount, - recoveryOptions: failure.recoveryOptions, - toolName: failure.toolName ?? null, - }, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "failed", - message: failure.category === "permission_denied" - ? "The planner was blocked by a permission issue, so I paused the run." - : failure.category === "provider_unreachable" - ? "The planner hit a launch issue, so I paused the run and opened recovery options." - : "The planner hit a launch issue, so I paused the run.", - }); - this.deps.onPlanningStartupFailure?.(failure); - } - - private handlePlanningStartupToolCall(toolName: string): void { - if (!this.isPlanningStartupGuardActive()) return; - const contracts = this.listActiveDelegationContracts(); - const permission = checkCoordinatorToolPermission({ - toolName, - contracts, - }); - if (!permission.allowed) { - const failure = this.buildNativeToolPlanningFailure(toolName); - this.handlePlanningStartupFailure(failure); - throw new CoordinatorFatalError( - permission.reason?.trim().length ? permission.reason : failure.message, - ); - } - - const normalizedToolName = normalizeCoordinatorToolName(toolName); - if (normalizedToolName === "get_project_context" && this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - launchState: "fetching_context", - }); - this.emitDelegationState( - this.pendingPlannerDelegationContract, - "I’m pulling project context so the planner starts with the right picture.", - ); - this.planningStartupState = "awaiting_planner_launch"; - this.updatePlannerLaunchTrackerStep({ - status: "running", - reason: "fetching_project_context", - }); - } - if (normalizedToolName === "spawn_worker" && this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - launchState: "launching_worker", - }); - this.emitDelegationState( - this.pendingPlannerDelegationContract, - "I’m starting the planning agent now.", - ); - if (this.planningStartupState !== "waiting_on_planner") { - this.planningStartupState = "awaiting_planner_launch"; - } - this.updatePlannerLaunchTrackerStep({ - status: "running", - reason: "launching_planner", - }); - } - } - - private isPlanningFirstPhaseRun(): boolean { - if (!Array.isArray(this.deps.phases) || this.deps.phases.length === 0) return false; - const firstPhase = [...this.deps.phases].sort((a, b) => a.position - b.position)[0]; - return firstPhase?.phaseKey.trim().toLowerCase() === "planning"; - } - - private resolveFirstPostPlanningPhase(): PhaseCard | null { - const sortedPhases = Array.isArray(this.deps.phases) - ? [...this.deps.phases].sort((a, b) => a.position - b.position) - : []; - const planningPhaseIndex = sortedPhases.findIndex((phase) => phase.phaseKey.trim().toLowerCase() === "planning"); - return sortedPhases.find((phase, index) => - index > planningPhaseIndex && phase.phaseKey.trim().toLowerCase() !== "planning" - ) ?? sortedPhases.find((phase) => phase.phaseKey.trim().toLowerCase() !== "planning") ?? null; - } - - private hasOpenPlanningClarification(): boolean { - const matchesPlanningClarification = (metadata: Record | null | undefined): boolean => { - const source = typeof metadata?.source === "string" ? metadata.source.trim().toLowerCase() : ""; - const phase = typeof metadata?.phase === "string" ? metadata.phase.trim().toLowerCase() : ""; - return source === "ask_user" || phase === "planning"; - }; - - try { - const rows = this.deps.db.all<{ metadata_json: string | null }>( - ` - select metadata_json - from mission_interventions - where mission_id = ? - and project_id = ? - and status = 'open' - and intervention_type = 'manual_input' - `, - [this.deps.missionId, this.deps.projectId], - ); - if (rows.some((row) => { - try { - return matchesPlanningClarification(asRecord(JSON.parse(row.metadata_json ?? "null"))); - } catch { - return false; - } - })) { - return true; - } - } catch { - // Fall back to mission service cache below. - } - - const mission = this.deps.missionService.get(this.deps.missionId); - return (mission?.interventions ?? []).some((intervention) => { - if (intervention.status !== "open" || intervention.interventionType !== "manual_input") return false; - return matchesPlanningClarification(asRecord(intervention.metadata)); - }); - } - - private planningQuestionRequiredBeforeExit(): boolean { - const planningPhase = [...(this.deps.phases ?? [])] - .sort((a, b) => a.position - b.position) - .find((phase) => phase.phaseKey.trim().toLowerCase() === "planning" || phase.name.trim().toLowerCase() === "planning") ?? null; - return phaseRequiresPlanningQuestionBeforeExit({ - phase: planningPhase, - missionPrompt: this.missionPromptForPlanningPolicy(), - }); - } - - private nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; - } - - private missionPromptForPlanningPolicy(): string | null { - const candidates: string[] = []; - const addCandidate = (value: unknown) => { - const candidate = this.nonEmptyString(value); - if (candidate && !candidates.includes(candidate)) candidates.push(candidate); - }; - addCandidate(this.deps.missionService.get(this.deps.missionId)?.prompt); - try { - const missionPromptRow = this.deps.db.get<{ prompt?: unknown }>( - `select prompt from missions where id = ? and project_id = ? limit 1`, - [this.deps.missionId, this.deps.projectId], - ); - addCandidate(missionPromptRow?.prompt); - } catch { - // Fall back to run metadata below. - } - try { - const runMetadataRow = this.deps.db.get<{ metadata_json?: unknown }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [this.deps.runId], - ); - const metadataJson = this.nonEmptyString(runMetadataRow?.metadata_json); - if (metadataJson) { - const metadata = asRecord(JSON.parse(metadataJson)); - addCandidate(metadata?.missionPrompt); - addCandidate(metadata?.missionGoal); - } - } catch { - // Fall back to the dependency value below. - } - addCandidate(this.deps.missionGoal); - return candidates.length > 0 ? candidates.join("\n\n") : null; - } - - private hasResolvedPlanningQuestion(): boolean { - const mission = this.deps.missionService.get(this.deps.missionId); - return hasResolvedPlanningQuestionIntervention(mission?.interventions ?? [], { - runId: this.deps.runId, - }); - } - - private hasPlanningExecutionRecord(): boolean { - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - return filterExecutionSteps(graph.steps).some((step) => this.isPlanningExecutionStep(step)); - } catch { - return false; - } - } - - private getCurrentPhaseKeyFromRun(): string | null { - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - const runMetadata = asRecord(graph.run.metadata); - const phaseRuntime = asRecord(runMetadata?.phaseRuntime); - const currentPhaseKey = typeof phaseRuntime?.currentPhaseKey === "string" - ? phaseRuntime.currentPhaseKey.trim() - : ""; - return currentPhaseKey.length > 0 ? currentPhaseKey : null; - } catch { - return null; - } - } - - private hasSucceededPlanningExecutionRecord(): boolean { - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - return filterExecutionSteps(graph.steps).some((step) => - this.isPlanningExecutionStep(step) && step.status === "succeeded" - ); - } catch { - return false; - } - } - - private resolvePostPlanningPhaseTransitionRequired(): PhaseTransitionRequiredSignal | null { - if (!this.isPlanningFirstPhaseRun()) return null; - if (this.hasOpenPlanningClarification()) return null; - const currentPhaseKey = this.getCurrentPhaseKeyFromRun()?.trim().toLowerCase(); - if (currentPhaseKey !== "planning") return null; - if (!this.hasSucceededPlanningExecutionRecord()) return null; - if (this.planningQuestionRequiredBeforeExit() && !this.hasResolvedPlanningQuestion()) return null; - const nextPhase = this.resolveFirstPostPlanningPhase(); - const nextPhaseKey = nextPhase?.phaseKey.trim(); - if (!nextPhaseKey || nextPhaseKey.toLowerCase() === "planning") return null; - return { - phaseKey: nextPhaseKey, - reason: "planning_complete", - }; - } - - private getRunGraphSnapshot(): ReturnType | null { - try { - return this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - } catch { - return null; - } - } - - private resolveCurrentPhaseCardFromRun(): PhaseCard | null { - const currentPhaseKey = this.getCurrentPhaseKeyFromRun(); - if (!currentPhaseKey) return null; - return [...(this.deps.phases ?? [])] - .sort((a, b) => a.position - b.position) - .find((phase) => phase.phaseKey === currentPhaseKey || phase.name === currentPhaseKey) - ?? null; - } - - private getExecutionStepsForPhase(phaseKey: string): OrchestratorStep[] { - const graph = this.getRunGraphSnapshot(); - if (!graph) return []; - const normalizedPhaseKey = phaseKey.trim().toLowerCase(); - return filterExecutionSteps(graph.steps).filter((step) => { - const metadata = asRecord(step.metadata); - const stepPhaseKey = typeof metadata?.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - return stepPhaseKey === normalizedPhaseKey; - }); - } - - private phaseHasSucceededExecutionStep(phaseKey: string): boolean { - return this.getExecutionStepsForPhase(phaseKey).some((step) => step.status === "succeeded"); - } - - private stepValidationState(step: OrchestratorStep): "pass" | "fail" | "pending" { - const metadata = asRecord(step.metadata) ?? {}; - const stateRaw = typeof metadata.validationState === "string" ? metadata.validationState.trim().toLowerCase() : ""; - if (stateRaw === "pass" || stateRaw === "passed" || stateRaw === "success" || stateRaw === "succeeded") return "pass"; - if (stateRaw === "fail" || stateRaw === "failed" || stateRaw === "failure") return "fail"; - const lastValidationReport = asRecord(metadata.lastValidationReport); - const reportVerdict = typeof lastValidationReport?.verdict === "string" - ? lastValidationReport.verdict.trim().toLowerCase() - : ""; - if (reportVerdict === "pass" || reportVerdict === "passed" || reportVerdict === "success" || reportVerdict === "succeeded") return "pass"; - if (reportVerdict === "fail" || reportVerdict === "failed" || reportVerdict === "failure") return "fail"; - const validationPassedAt = typeof metadata.validationPassedAt === "string" ? metadata.validationPassedAt.trim() : ""; - if (validationPassedAt.length > 0) return "pass"; - return "pending"; - } - - private stepRequiredValidationPending(step: OrchestratorStep): boolean { - const metadata = asRecord(step.metadata) ?? {}; - const contract = asRecord(metadata.validationContract); - if (contract?.required !== true) return false; - return this.stepValidationState(step) !== "pass"; - } - - private stepRequiresPendingSelfValidation(step: OrchestratorStep): boolean { - const metadata = asRecord(step.metadata) ?? {}; - const contract = asRecord(metadata.validationContract); - if (contract?.required !== true) return false; - const tier = typeof contract.tier === "string" ? contract.tier.trim().toLowerCase() : ""; - return tier === "self" && this.stepValidationState(step) !== "pass"; - } - - private isTestingLikePhaseValue(value: string): boolean { - const normalized = value.trim().toLowerCase().replace(/[_-]+/g, " "); - if (!normalized.length) return false; - return /\b(test|testing|qa|quality|verify|verification|validation|validate|closeout|handoff|acceptance)\b/.test(normalized); - } - - private isImplementationLikeStep(step: OrchestratorStep): boolean { - const metadata = asRecord(step.metadata) ?? {}; - const stepType = typeof metadata.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - const phaseKey = typeof metadata.phaseKey === "string" ? metadata.phaseKey : ""; - const phaseName = typeof metadata.phaseName === "string" ? metadata.phaseName : ""; - if (this.isTestingLikePhaseValue(stepType) || this.isTestingLikePhaseValue(phaseKey) || this.isTestingLikePhaseValue(phaseName)) { - return false; - } - return stepType === "implementation" - || /\b(implement|implementation|develop|development|build|code|coding)\b/.test(phaseKey.trim().toLowerCase().replace(/[_-]+/g, " ")) - || /\b(implement|implementation|develop|development|build|code|coding)\b/.test(phaseName.trim().toLowerCase().replace(/[_-]+/g, " ")); - } - - private hasLaterTestingLikePhase(step: OrchestratorStep): boolean { - const metadata = asRecord(step.metadata) ?? {}; - const phaseKey = typeof metadata.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - const currentPhase = this.deps.phases?.find((phase) => phase.phaseKey.trim().toLowerCase() === phaseKey); - if (!currentPhase) return false; - return (this.deps.phases ?? []).some((phase) => { - if (phase.position <= currentPhase.position) return false; - return this.isTestingLikePhaseValue(phase.phaseKey) || this.isTestingLikePhaseValue(phase.name); - }); - } - - private shouldDeferFailedTestsToFinalValidation( - step: OrchestratorStep, - report: Record, - failedCount: number, - ): boolean { - if (failedCount <= 0) return false; - const outcome = typeof report.outcome === "string" ? report.outcome.trim().toLowerCase() : ""; - if (outcome !== "succeeded" && outcome !== "completed" && outcome !== "pass" && outcome !== "passed") return false; - if (!this.isImplementationLikeStep(step)) return false; - return this.hasLaterTestingLikePhase(step); - } - - private buildSelfValidationDecision(step: OrchestratorStep): SelfValidationDecision | null { - const metadata = asRecord(step.metadata) ?? {}; - const report = asRecord(metadata.lastResultReport); - if (!report) return null; - const summary = typeof report.summary === "string" ? report.summary.trim() : ""; - if (!summary.length) return null; - const outcome = typeof report.outcome === "string" ? report.outcome.trim().toLowerCase() : ""; - const testsRun = asRecord(report.testsRun); - const failedCount = testsRun && Number.isFinite(Number(testsRun.failed)) - ? Math.max(0, Math.floor(Number(testsRun.failed))) - : 0; - const testsSummary = - testsRun && ( - Number.isFinite(Number(testsRun.passed)) - || Number.isFinite(Number(testsRun.failed)) - || Number.isFinite(Number(testsRun.skipped)) - || typeof testsRun.raw === "string" - ) - ? [ - Number.isFinite(Number(testsRun.passed)) ? `passed=${Math.max(0, Math.floor(Number(testsRun.passed)))}` : null, - Number.isFinite(Number(testsRun.failed)) ? `failed=${failedCount}` : null, - Number.isFinite(Number(testsRun.skipped)) ? `skipped=${Math.max(0, Math.floor(Number(testsRun.skipped)))}` : null, - ].filter((entry): entry is string => Boolean(entry)).join(", ") - : ""; - - const deferFailedTests = this.shouldDeferFailedTestsToFinalValidation(step, report, failedCount); - if (outcome === "failed" || outcome === "partial" || (failedCount > 0 && !deferFailedTests)) { - return { - verdict: "fail", - summary: [ - `Self-validation failed for ${step.stepKey}: ${summary}`, - testsSummary ? `Tests: ${testsSummary}` : null, - ].filter((entry): entry is string => Boolean(entry)).join(" "), - findings: [ - { - code: "worker_result_not_validated", - severity: "high", - message: failedCount > 0 - ? `Worker reported ${failedCount} failed test(s).` - : `Worker reported outcome "${outcome || "unknown"}".`, - remediation: "Fix the reported issues, rerun the relevant checks, and report validation again.", - }, - ], - }; - } - - return { - verdict: "pass", - summary: [ - `Self-validation passed for ${step.stepKey}: ${summary}`, - deferFailedTests - ? "Reported test failures are deferred to the final validation phase because this implementation worker can observe sibling phase work before it lands." - : null, - testsSummary ? `Tests: ${testsSummary}` : null, - ].filter((entry): entry is string => Boolean(entry)).join(" "), - findings: deferFailedTests - ? [ - { - code: "integration_tests_deferred", - severity: "medium", - message: `Worker reported ${failedCount} failed test(s) during an implementation phase; final validation must rerun after sibling phase work lands.`, - remediation: "Continue to the configured testing or closeout phase and require the final checks to pass.", - }, - ] - : [], - }; - } - - private async applySelfValidationDecisionFromRuntime( - step: OrchestratorStep, - decision: SelfValidationDecision, - turnId: string, - ): Promise { - const tool = this.tools.report_validation as any; - if (!tool || typeof tool.execute !== "function") return false; - const metadata = asRecord(step.metadata) ?? {}; - const contract = asRecord(metadata.validationContract) ?? { - level: "step", - tier: "self", - required: true, - criteria: `Validate completed output for step "${step.stepKey}".`, - evidence: [], - maxRetries: 1, - }; - const args = { - targetWorkerId: step.stepKey, - contract, - verdict: decision.verdict, - summary: decision.summary, - findings: decision.findings, - remediationInstructions: decision.verdict === "fail" - ? ["Fix the reported issues and rerun validation."] - : [], - }; - this.deps.logger.warn("coordinator_agent.self_validation_runtime_recovery_applying", { - runId: this.deps.runId, - turnId, - stepKey: step.stepKey, - verdict: decision.verdict, - }); - this.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: "report_validation", - args, - itemId: `runtime:${turnId}:self-validation:${step.stepKey}`, - turnId, - }); - const output = await tool.execute(args); - const result = coerceToolOutputRecord(output); - this.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: "report_validation", - result: output, - itemId: `runtime:${turnId}:self-validation:${step.stepKey}`, - turnId, - status: result?.ok === false ? "failed" : "completed", - }); - if (result?.ok === false) { - const error = typeof result.error === "string" ? result.error : "report_validation failed."; - this.queueInternalMessage([ - "SELF-VALIDATION REPORT FAILED:", - `ADE tried to record self-validation for "${step.stepKey}", but report_validation failed: ${error}`, - "Call report_validation with corrected arguments before moving to the next phase.", - ].join("\n")); - return false; - } - this.queueInternalMessage([ - "SELF-VALIDATION RECORDED:", - `ADE recorded a ${decision.verdict} self-validation verdict for "${step.stepKey}" from the worker's structured result report.`, - decision.verdict === "pass" - ? "Continue phase progression through the configured phase order." - : "Do not advance the phase until the worker output is repaired and validation passes.", - ].join("\n")); - return true; - } - - private async enforcePendingSelfValidation(turnId: string): Promise<"ok" | "validated"> { - const graph = this.getRunGraphSnapshot(); - if (!graph) return "ok"; - const currentPhase = this.resolveCurrentPhaseCardFromRun(); - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - const candidates = filterExecutionSteps(graph.steps) - .filter((step) => step.status === "succeeded") - .filter((step) => this.stepRequiresPendingSelfValidation(step)) - .sort((a, b) => { - const aMeta = asRecord(a.metadata) ?? {}; - const bMeta = asRecord(b.metadata) ?? {}; - const aInCurrent = typeof aMeta.phaseKey === "string" && aMeta.phaseKey.trim().toLowerCase() === currentPhaseKey; - const bInCurrent = typeof bMeta.phaseKey === "string" && bMeta.phaseKey.trim().toLowerCase() === currentPhaseKey; - if (aInCurrent !== bInCurrent) return aInCurrent ? -1 : 1; - return (a.completedAt ?? a.updatedAt).localeCompare(b.completedAt ?? b.updatedAt); - }); - for (const step of candidates) { - const decision = this.buildSelfValidationDecision(step); - if (!decision) continue; - if (await this.applySelfValidationDecisionFromRuntime(step, decision, turnId)) { - return "validated"; - } - } - return "ok"; - } - - private phaseHasOpenExecutionStep(phaseKey: string): boolean { - return this.getExecutionStepsForPhase(phaseKey).some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); - } - - private phaseHasAnyExecutionStep(phaseKey: string): boolean { - return this.getExecutionStepsForPhase(phaseKey).length > 0; - } - - private getSortedPhaseCards(): PhaseCard[] { - return [...(this.deps.phases ?? [])].sort((a, b) => a.position - b.position); - } - - private getExecutionStepsForPhaseFromGraph( - graph: ReturnType, - phase: PhaseCard, - ): OrchestratorStep[] { - const normalizedPhaseKey = phase.phaseKey.trim().toLowerCase(); - const normalizedPhaseName = phase.name.trim().toLowerCase(); - return filterExecutionSteps(graph.steps).filter((step) => { - const metadata = asRecord(step.metadata) ?? {}; - const stepPhaseKey = typeof metadata.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - const stepPhaseName = typeof metadata.phaseName === "string" ? metadata.phaseName.trim().toLowerCase() : ""; - return stepPhaseKey === normalizedPhaseKey || stepPhaseName === normalizedPhaseName; - }); - } - - private phaseHasValidatedSuccess( - graph: ReturnType, - phase: PhaseCard, - ): boolean { - const steps = this.getExecutionStepsForPhaseFromGraph(graph, phase); - if (steps.length === 0) return false; - const successfulSteps = steps.filter((step) => step.status === "succeeded"); - if (successfulSteps.length === 0) return false; - return successfulSteps.some((step) => !this.stepRequiredValidationPending(step)); - } - - private phaseHasOpenExecutionStepInGraph( - graph: ReturnType, - phase: PhaseCard, - ): boolean { - return this.getExecutionStepsForPhaseFromGraph(graph, phase) - .some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); - } - - private resolvePhaseDependencyStepKeys( - graph: ReturnType, - phase: PhaseCard, - ): string[] { - const sorted = this.getSortedPhaseCards(); - const targetIndex = sorted.findIndex((candidate) => candidate.phaseKey === phase.phaseKey); - const explicitMustFollow = (phase.orderingConstraints.mustFollow ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - const predecessorPhases = - explicitMustFollow.length > 0 - ? explicitMustFollow - .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 resolveMustPrecedePredecessors(sorted, phase)) { - if (!predecessorPhases.includes(predecessor)) predecessorPhases.push(predecessor); - } - const dependencyKeys: string[] = []; - for (const predecessor of predecessorPhases) { - const successful = this.getExecutionStepsForPhaseFromGraph(graph, predecessor) - .filter((step) => step.status === "succeeded") - .sort((a, b) => (b.completedAt ?? b.updatedAt).localeCompare(a.completedAt ?? a.updatedAt)); - const stepKey = successful[0]?.stepKey; - if (stepKey && !dependencyKeys.includes(stepKey)) dependencyKeys.push(stepKey); - } - return dependencyKeys; - } - - private phaseCanStartNow( - graph: ReturnType, - phase: PhaseCard, - ): boolean { - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - if (!phaseKey || phaseKey === "planning" || phaseName === "planning") return false; - if (phase.requiresApproval === true) return false; - if (this.getExecutionStepsForPhaseFromGraph(graph, phase).length > 0) return false; - const sorted = this.getSortedPhaseCards(); - const targetIndex = sorted.findIndex((candidate) => candidate.phaseKey === phase.phaseKey); - if (targetIndex < 0) return false; - const explicitMustFollow = (phase.orderingConstraints.mustFollow ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - for (let index = 0; index < targetIndex; index += 1) { - const earlier = sorted[index]!; - if (earlier.orderingConstraints.mustBeFirst && !this.phaseHasValidatedSuccess(graph, earlier)) return false; - if (explicitMustFollow.length === 0 && earlier.validationGate.required && !this.phaseHasValidatedSuccess(graph, earlier)) return false; - if (phase.orderingConstraints.mustBeLast && this.phaseHasOpenExecutionStepInGraph(graph, earlier)) return false; - } - for (const predecessorKey of explicitMustFollow) { - const predecessor = findPhaseByRef(sorted, predecessorKey); - if (predecessor && !this.phaseHasValidatedSuccess(graph, predecessor)) return false; - } - for (const predecessor of resolveMustPrecedePredecessors(sorted, phase)) { - if (!this.phaseHasValidatedSuccess(graph, predecessor)) return false; - } - return true; - } - - private buildRuntimePhaseStepMetadata(phase: PhaseCard, modelId: string, workerName: string): Record { - const descriptor = resolveModelDescriptor(modelId); - const provider = descriptor ? resolveProviderGroupForModel(descriptor) : "opencode"; - const stepType = this.resolveRuntimePhaseStepType(phase); - const workerPrompt = this.buildRuntimePhaseWorkerPrompt(phase); - const requiredBeforeExit = phaseRequiresPlanningQuestionBeforeExit({ - phase, - missionPrompt: [this.missionPromptForPlanningPolicy(), workerPrompt] - .map((entry) => this.nonEmptyString(entry)) - .filter((entry): entry is string => Boolean(entry)) - .join("\n\n") || null, - }); - return { - phaseKey: phase.phaseKey, - phaseName: phase.name, - phasePosition: phase.position, - phaseModel: phase.model, - phaseInstructions: phase.instructions, - phaseAskQuestions: { - enabled: phase.askQuestions.enabled === true, - maxQuestions: phase.askQuestions.enabled === true - ? Math.max(1, Math.min(10, Number(phase.askQuestions.maxQuestions ?? 5) || 5)) - : undefined, - requiredBeforeExit, - }, - phaseValidation: phase.validationGate, - phaseBudget: phase.budget ?? {}, - stepType, - taskType: stepType, - readOnlyExecution: stepType === "planning", - instructions: workerPrompt, - workerName, - spawnedByCoordinator: true, - modelId, - modelProviderHint: provider, - modelExecutionPath: descriptor ? classifyWorkerExecutionPath(descriptor) : "api", - role: null, - roleCapabilities: [], - toolProfile: null, - }; - } - - private resolveRuntimePhaseStepType(phase: PhaseCard): string { - const normalized = phase.phaseKey.trim().toLowerCase(); - if (normalized === "planning" || normalized === "analysis") return "planning"; - if ( - normalized === "development" - || normalized === "implementation" - || normalized.startsWith("development_") - || normalized.startsWith("implementation_") - || normalized.endsWith("_development") - || normalized.endsWith("_implementation") - ) return "implementation"; - if (normalized === "testing" || normalized === "test") return "testing"; - if (normalized === "validation") return "validation"; - if (normalized === "code_review" || normalized === "review") return "review"; - if (normalized === "test_review") return "test_review"; - if (normalized === "integration" || normalized === "merge") return "integration"; - return normalized || "implementation"; - } - - private async spawnReadySiblingPhaseWorkersFromRuntime(turnId: string): Promise { - if (this.deps.userRules?.allowParallelAgents === false) return 0; - const graph = this.getRunGraphSnapshot(); - if (!graph) return 0; - const sorted = this.getSortedPhaseCards(); - if (sorted.length === 0) return 0; - const openExecutionCount = filterExecutionSteps(graph.steps) - .filter((step) => !TERMINAL_STEP_STATUSES.has(step.status)) - .length; - const configuredCap = Number(this.deps.userRules?.maxParallelWorkers ?? 0); - const maxParallelWorkers = Number.isFinite(configuredCap) && configuredCap > 0 - ? Math.max(1, Math.floor(configuredCap)) - : 4; - let remainingSlots = Math.max(0, maxParallelWorkers - openExecutionCount); - if (remainingSlots <= 0) return 0; - - const currentPhaseKey = this.getCurrentPhaseKeyFromRun()?.trim().toLowerCase() ?? ""; - const readyPhases = sorted - .filter((phase) => phase.phaseKey.trim().toLowerCase() !== currentPhaseKey) - .filter((phase) => this.phaseCanStartNow(graph, phase)) - .slice(0, remainingSlots); - if (readyPhases.length === 0) return 0; - - let created = 0; - for (const phase of readyPhases) { - if (remainingSlots <= 0) break; - const modelId = phase.model?.modelId?.trim() || this.deps.modelId; - const descriptor = resolveModelDescriptor(modelId); - const provider = descriptor ? resolveProviderGroupForModel(descriptor) : "opencode"; - const workerName = `${phase.phaseKey.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "phase"}-worker`; - const stepKey = `worker_${workerName.replace(/[^a-zA-Z0-9_-]/g, "_")}_${Date.now() + created}`; - const dependencyStepKeys = this.resolvePhaseDependencyStepKeys(graph, phase); - this.deps.logger.warn("coordinator_agent.ready_phase_runtime_worker_spawning", { - runId: this.deps.runId, - turnId, - phaseKey: phase.phaseKey, - workerName, - dependencyStepKeys, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: `ADE is starting ready phase "${phase.phaseKey}" in parallel because its phase dependencies are satisfied.`, - }); - const steps = this.deps.orchestratorService.addSteps({ - runId: this.deps.runId, - steps: [ - { - stepKey, - title: workerName, - stepIndex: graph.steps.length + created, - laneId: this.deps.missionLaneId ?? null, - dependencyStepKeys, - executorKind: provider, - metadata: this.buildRuntimePhaseStepMetadata(phase, modelId, workerName), - }, - ], - } as any); - const newStep = Array.isArray(steps) ? steps[0] : null; - if (newStep) { - this.deps.onDagMutation({ - runId: this.deps.runId, - mutation: { type: "step_added", step: newStep }, - timestamp: new Date().toISOString(), - source: "coordinator", - }); - } - created += 1; - remainingSlots -= 1; - } - if (created > 0 && typeof (this.deps.orchestratorService as any).startReadyAutopilotAttempts === "function") { - try { - await (this.deps.orchestratorService as any).startReadyAutopilotAttempts({ - runId: this.deps.runId, - reason: "ready_phase_parallel_runtime_spawn", - }); - } catch { - // The scheduler tick will pick up queued steps on the next pass. - } - } - return created; - } - - private resolveNextPhaseAfter(phaseKey: string): PhaseCard | null { - const sortedPhases = [...(this.deps.phases ?? [])].sort((a, b) => a.position - b.position); - const currentIndex = sortedPhases.findIndex((phase) => phase.phaseKey === phaseKey); - if (currentIndex < 0) return null; - return sortedPhases.slice(currentIndex + 1).find((phase) => phase.phaseKey.trim().length > 0) ?? null; - } - - private getLatestPlanningResultForPrompt(): string { - const graph = this.getRunGraphSnapshot(); - if (!graph) return ""; - const planningSteps = filterExecutionSteps(graph.steps) - .filter((step) => this.isPlanningExecutionStep(step) && step.status === "succeeded") - .sort((a, b) => (b.completedAt ?? b.updatedAt).localeCompare(a.completedAt ?? a.updatedAt)); - for (const step of planningSteps) { - const metadata = asRecord(step.metadata); - const report = asRecord(metadata?.lastResultReport); - const plan = asRecord(report?.plan); - const markdown = typeof plan?.markdown === "string" ? plan.markdown.trim() : ""; - if (markdown.length > 0) return markdown.slice(0, 6000); - const summary = typeof report?.summary === "string" ? report.summary.trim() : ""; - if (summary.length > 0) return summary.slice(0, 2000); - } - return ""; - } - - private buildPlanningRecoveryPrompt(): { name: string; prompt: string; modelId?: string } { - const phases = Array.isArray(this.deps.phases) ? [...this.deps.phases] : []; - const planningPhase = phases - .sort((a, b) => a.position - b.position) - .find((phase) => phase.phaseKey.trim().toLowerCase() === "planning") ?? null; - const sections = [ - `Mission goal:\n${this.deps.missionGoal}`, - planningPhase?.instructions?.trim().length - ? `Planning phase instructions:\n${planningPhase.instructions.trim()}` - : null, - [ - "Read-only planning pass:", - "- Research the codebase and discover the implementation plan.", - "- Identify dependencies, risks, sequencing, and the best execution DAG.", - "- Do not modify files, run write operations, or ask for plan-exit approval.", - "- Do NOT use ExitPlanMode or any provider-native plan approval flow.", - "- Return the plan through the runtime result channel: tracked mission workers use report_result; managed ADE chat workers use a final `### report_result` section with plan.markdown. ADE will persist the canonical plan artifact.", - "- If you need clarification, use the runtime's ADE question UI: tracked mission workers use `ask_user`; managed Codex chat workers use `request_user_input`; Claude uses AskUserQuestion / ask_user; Cursor/OpenCode/Droid use the provider question mechanism ADE renders inline.", - "- Return a concrete plan the coordinator can use to enter Development automatically.", - ].join("\n"), - ].filter((entry): entry is string => Boolean(entry)); - - return { - name: "planning-worker", - prompt: sections.join("\n\n"), - ...(planningPhase?.model?.modelId ? { modelId: planningPhase.model.modelId } : {}), - }; - } - - private async launchRequiredPlanningWorkerFromRuntime(turnId: string): Promise { - const tool = this.tools.spawn_worker as any; - if (!tool || typeof tool.execute !== "function") return false; - const plannerPrompt = this.buildPlanningRecoveryPrompt(); - this.deps.logger.warn("coordinator_agent.planning_runtime_recovery_launching", { - runId: this.deps.runId, - turnId, - retryCount: this.planningStartupRetryCount, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: "The coordinator did not start the planner, so ADE is launching the required planning worker through mission control.", - }); - const result = coerceToolOutputRecord(await tool.execute({ - ...plannerPrompt, - dependsOn: [], - })); - if (result?.ok !== true) { - const message = typeof result?.error === "string" ? result.error : "Runtime planner launch failed."; - const failure = this.buildPlannerLaunchFailure(message, "spawn_worker"); - this.handlePlanningStartupFailure({ - ...failure, - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - retryCount: this.planningStartupRetryCount, - }); - return false; - } - - const delegatedContract = extractDelegationContract(result.delegationContract ?? null); - if (delegatedContract && delegatedContract.workerIntent === "planner") { - this.pendingPlannerDelegationContract = delegatedContract; - } else if (this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - status: "active", - launchState: "waiting_on_worker", - activeWorkerIds: - typeof result.workerId === "string" && result.workerId.trim().length > 0 - ? [result.workerId.trim()] - : this.pendingPlannerDelegationContract.activeWorkerIds, - }); - } - this.planningStartupState = "waiting_on_planner"; - if (this.pendingPlannerDelegationContract) { - this.emitDelegationState( - this.pendingPlannerDelegationContract, - result.launched === false - ? "The planning agent is queued. I’m waiting for it to start." - : "The planning agent is running. I’m waiting for its result.", - ); - } - this.updatePlannerLaunchTrackerStep({ - status: "running", - reason: result.launched === false ? "planner_queued" : "planner_runtime_recovery_launched", - detail: { - launched: result.launched ?? null, - launchNote: typeof result.launchNote === "string" ? result.launchNote : null, - stepId: typeof result.stepId === "string" ? result.stepId : null, - source: "runtime_planner_recovery", - }, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "started", - turnId, - message: result.launched === false - ? "The planning agent is queued. I’m waiting for it to start." - : "The planning agent is running. I’m waiting for its result.", - }); - return true; - } - - private async applyRequiredPhaseTransitionFromRuntime( - signal: PhaseTransitionRequiredSignal, - turnId: string, - ): Promise { - const phaseKey = signal.phaseKey.trim(); - if (!phaseKey.length) return false; - const tool = this.tools.set_current_phase as any; - if (!tool || typeof tool.execute !== "function") return false; - this.deps.logger.warn("coordinator_agent.phase_transition_runtime_recovery_applying", { - runId: this.deps.runId, - turnId, - phaseKey, - reason: signal.reason, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: `The coordinator did not enter phase "${phaseKey}", so ADE is applying the required phase transition through mission control.`, - }); - const result = coerceToolOutputRecord(await tool.execute({ - phaseKey, - reason: signal.reason || "planning_complete", - })); - if (result?.ok !== true) { - if (result?.pendingApproval === true) { - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: `Phase "${phaseKey}" is waiting for approval before the mission can continue.`, - }); - return true; - } - const error = typeof result?.error === "string" && result.error.trim().length > 0 - ? result.error.trim() - : `set_current_phase did not enter phase '${phaseKey}'.`; - this.deps.onCoordinatorEvent?.({ - type: "error", - turnId, - message: error, - }); - throw new CoordinatorFatalError( - error, - { - category: "unknown", - reasonCode: "phase_transition_runtime_recovery_failed", - interventionType: "policy_block", - retryable: true, - recoveryOptions: ["retry", "cancel_run"], - message: error, - title: "ADE could not enter the required phase", - body: - `ADE attempted to enter '${phaseKey}' through the normal set_current_phase tool after the coordinator ignored the phase contract, but the tool failed. ` + - "The run was paused instead of letting phase ordering drift.", - requestedAction: "Inspect the phase configuration and planner result, then retry or cancel the run.", - turnId, - }, - ); - } - - this.phaseTransitionCorrectionCounts.delete(phaseKey); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "started", - turnId, - message: `Mission phase is now "${String(result.currentPhaseName ?? phaseKey)}".`, - }); - this.queueInternalMessage([ - "PHASE TRANSITION COMPLETE:", - `ADE entered phase "${phaseKey}" through set_current_phase because planning had completed and the coordinator did not execute the phase transition itself.`, - "Continue with the implementation DAG now. Create visible tasks when useful, then spawn workers according to the completed planning output and configured phase order.", - ].join("\n")); - return true; - } - - private shouldIdleAfterRecoveredToolCalls(toolNames: string[]): boolean { - if (toolNames.length === 0) return false; - if (this.isPlanningStartupGuardActive()) return false; - if (!toolNames.every((toolName) => RECOVERED_IDLE_WHILE_WORKERS_RUNNING_TOOLS.has(toolName))) { - return false; - } - - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - const executionSteps = filterExecutionSteps(graph.steps); - const runningCount = executionSteps.filter((step) => step.status === "running").length; - if (runningCount === 0) return false; - - const hasActionableStep = executionSteps.some((step) => - step.status === "ready" || step.status === "blocked" || step.status === "failed" - ); - return !hasActionableStep; - } catch { - return false; - } - } - - private getRecoveredToolExecutionContext(): RecoveredToolExecutionContext { - try { - const graph = this.deps.orchestratorService.getRunGraph({ - runId: this.deps.runId, - timelineLimit: 0, - }); - const executionSteps = filterExecutionSteps(graph.steps); - const hadRunningWorkers = executionSteps.some((step) => step.status === "running"); - const hadActionableSteps = executionSteps.some((step) => - step.status === "ready" || step.status === "blocked" || step.status === "failed" - ); - return { hadRunningWorkers, hadActionableSteps }; - } catch { - return { hadRunningWorkers: false, hadActionableSteps: true }; - } - } - - private shouldSuppressRecoveredSpawnWorker(context: RecoveredToolExecutionContext): boolean { - if (this.isPlanningStartupGuardActive()) return false; - return context.hadRunningWorkers && !context.hadActionableSteps; - } - - private coerceRecoveredToolArgs(toolName: string, args: Record): Record { - const normalizedToolName = normalizeCoordinatorToolName(toolName); - if (normalizedToolName === "spawn_worker") { - const laneId = typeof args.laneId === "string" && args.laneId.trim().length > 0 - ? args.laneId.trim() - : typeof args.lane === "string" && args.lane.trim().length > 0 - ? args.lane.trim() - : undefined; - return { - ...args, - ...(laneId ? { laneId } : {}), - }; - } - if (normalizedToolName === "skip_step") { - const workerId = typeof args.workerId === "string" && args.workerId.trim().length > 0 - ? args.workerId.trim() - : typeof args.stepKey === "string" && args.stepKey.trim().length > 0 - ? args.stepKey.trim() - : typeof args.stepId === "string" && args.stepId.trim().length > 0 - ? args.stepId.trim() - : typeof args.id === "string" && args.id.trim().length > 0 - ? args.id.trim() - : undefined; - return { - ...args, - ...(workerId ? { workerId } : {}), - }; - } - return args; - } - - private async executeRecoveredCoordinatorToolCalls( - assistantText: string, - turnId: string, - ): Promise { - const calls = parseRecoveredCoordinatorToolCalls(assistantText) - .slice(0, MAX_TOOL_STEPS_PER_TURN); - if (calls.length === 0) return 0; - - let executed = 0; - let handled = 0; - let spawnedWorker = false; - let statusProbeOnly = true; - let hasToolFailure = false; - const executedToolNames: string[] = []; - const resultSummaries: string[] = []; - const executionContext = this.getRecoveredToolExecutionContext(); - this.deps.logger.warn("coordinator_agent.recovering_pseudo_tool_calls", { - runId: this.deps.runId, - turnId, - count: calls.length, - tools: calls.map((call) => call.toolName), - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: "The coordinator wrote mission-control calls as text, so ADE is replaying those calls through the real mission-control tools.", - }); - - for (const call of calls) { - const normalizedToolName = normalizeCoordinatorToolName(call.toolName); - const tool = this.tools[normalizedToolName]; - if (!tool) { - this.deps.logger.warn("coordinator_agent.recovered_tool_missing", { - runId: this.deps.runId, - turnId, - toolName: call.toolName, - normalizedToolName, - }); - continue; - } - const args = this.coerceRecoveredToolArgs(normalizedToolName, call.args); - if (normalizedToolName === "spawn_worker" && this.shouldSuppressRecoveredSpawnWorker(executionContext)) { - handled += 1; - this.deps.logger.info("coordinator_agent.recovered_spawn_worker_suppressed", { - runId: this.deps.runId, - turnId, - reason: "workers_running_no_actionable_steps", - args, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: "ADE ignored a recovered spawn_worker call because workers are already running and no actionable step needs a new worker.", - }); - continue; - } - if (this.isPlanningStartupGuardActive()) { - const permission = checkCoordinatorToolPermission({ - toolName: normalizedToolName, - contracts: this.listActiveDelegationContracts(), - }); - if (!permission.allowed) { - this.deps.logger.info("coordinator_agent.recovered_tool_skipped_planner_delegation_active", { - runId: this.deps.runId, - turnId, - toolName: normalizedToolName, - reason: permission.reason, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: permission.reason, - }); - this.queueInternalMessage([ - "RECOVERED TOOL CALL SKIPPED:", - permission.reason, - "A planner delegation is already active. Wait for that planner or retry the planner through the recovery path instead of replaying status probes.", - ].join("\n")); - continue; - } - } - this.handlePlanningStartupToolCall(normalizedToolName); - this.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: normalizedToolName, - args, - itemId: `recovered:${turnId}:${executed + 1}`, - turnId, - }); - const output = await tool.execute(args); - const toolOutput = coerceToolOutputRecord(output); - executed += 1; - handled += 1; - executedToolNames.push(normalizedToolName); - resultSummaries.push( - `- ${normalizedToolName}: ${stringifyRecoveredToolOutputForPrompt(toolOutput ?? output)}`, - ); - if (!RECOVERED_STATUS_PROBE_TOOLS.has(normalizedToolName)) { - statusProbeOnly = false; - } - this.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: normalizedToolName, - result: output, - itemId: `recovered:${turnId}:${executed}`, - turnId, - status: toolOutput?.ok === false ? "failed" : "completed", - }); - const transitionSignal = extractPhaseTransitionRequiredSignal(toolOutput); - if (transitionSignal) { - this.queuePhaseTransitionCorrection(transitionSignal, turnId); - break; - } - if (normalizedToolName === "set_current_phase" && toolOutput?.ok === true) { - this.phaseTransitionCorrectionCounts.clear(); - } - if (normalizedToolName === "spawn_worker" && toolOutput?.ok === true) { - spawnedWorker = true; - } - if (toolOutput?.ok === false) { - hasToolFailure = true; - const error = typeof toolOutput.error === "string" ? toolOutput.error : `${normalizedToolName} failed.`; - const correction = this.deps.agentChatService - ? "Correct the tool arguments and continue by emitting fenced JSON mission-control calls." - : "Correct the tool arguments and continue through real mission-control tools. Do not describe calls in markdown."; - this.queueInternalMessage([ - "RECOVERED TOOL RESULT:", - `The recovered ${normalizedToolName} call failed: ${error}`, - correction, - ].join("\n")); - break; - } - } - - if (executed > 0 && !spawnedWorker) { - if (!hasToolFailure && this.shouldIdleAfterRecoveredToolCalls(executedToolNames)) { - this.deps.logger.info("coordinator_agent.recovered_tool_followup_suppressed", { - runId: this.deps.runId, - turnId, - tools: executedToolNames, - reason: "workers_running_no_actionable_steps", - }); - return executed; - } - const resultBlock = resultSummaries.length > 0 - ? [ - "Recovered tool outputs:", - joinRecoveredToolResultSummaries(resultSummaries), - ].join("\n") - : "Recovered tools completed but did not return structured output."; - const nextCallGuidance = this.deps.agentChatService - ? statusProbeOnly - ? "Use these concrete results to choose the next action. Do not repeat the same read-only status probes unless state has changed; emit the state-changing mission-control call needed to validate, skip, retry, or complete the mission." - : "Continue the mission by emitting fenced JSON mission-control calls. Do not answer that mission-control tools are unavailable." - : statusProbeOnly - ? "Use these concrete results to choose the next action. Do not repeat the same read-only status probes unless state has changed; call the state-changing mission-control tool needed to validate, skip, retry, or complete the mission." - : "Continue the mission using actual tool calls now. Do not describe tool calls in markdown/code fences."; - this.queueInternalMessage([ - "RECOVERED TOOL CALLS EXECUTED:", - "ADE replayed the coordinator's text-formatted mission-control calls through the real tools.", - resultBlock, - nextCallGuidance, - ].join("\n")); - } - return handled; - } - - private buildRuntimePhaseWorkerPrompt(phase: PhaseCard): string { - const plannerOutput = this.getLatestPlanningResultForPrompt(); - return [ - `Mission goal:\n${this.deps.missionGoal}`, - `Active phase: ${phase.name} (${phase.phaseKey})`, - phase.instructions.trim().length > 0 - ? `Phase instructions:\n${phase.instructions.trim()}` - : null, - plannerOutput.trim().length > 0 - ? `Planning output to follow:\n${plannerOutput.trim()}` - : null, - [ - "Execution protocol:", - "- Work only in the mission lane worktree.", - "- Make the concrete code/doc/test changes required by this phase.", - "- Use the existing project style and package scripts.", - "- If this phase owns only part of the mission, do not finish the whole mission; report your phase result.", - "- Before exiting, call report_result with outcome, summary, filesChanged, and testsRun when applicable.", - ].join("\n"), - ].filter((section): section is string => Boolean(section)).join("\n\n"); - } - - private async spawnRequiredPhaseWorkerFromRuntime( - phase: PhaseCard, - turnId: string, - ): Promise { - const tool = this.tools.spawn_worker as any; - if (!tool || typeof tool.execute !== "function") return false; - const workerName = `${phase.phaseKey.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "phase"}-worker`; - this.deps.logger.warn("coordinator_agent.active_phase_runtime_worker_spawning", { - runId: this.deps.runId, - turnId, - phaseKey: phase.phaseKey, - workerName, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: `The coordinator did not start work for phase "${phase.phaseKey}", so ADE is starting the configured phase worker through mission control.`, - }); - const result = coerceToolOutputRecord(await tool.execute({ - name: workerName, - modelId: phase.model?.modelId, - prompt: this.buildRuntimePhaseWorkerPrompt(phase), - })); - this.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: "spawn_worker", - result, - itemId: `runtime:${turnId}:spawn:${phase.phaseKey}`, - turnId, - status: result?.ok === false ? "failed" : "completed", - }); - if (result?.ok !== true) { - const transitionSignal = extractPhaseTransitionRequiredSignal(result); - if (transitionSignal) { - this.queuePhaseTransitionCorrection(transitionSignal, turnId); - return true; - } - const error = typeof result?.error === "string" ? result.error : `Could not start worker for phase '${phase.phaseKey}'.`; - this.queueInternalMessage([ - "RUNTIME PHASE WORKER FAILED:", - error, - "Correct the phase/task state and continue with real mission-control tools.", - ].join("\n")); - return true; - } - return true; - } - - private async enforceActivePhaseProgress(turnId: string): Promise<"ok" | "transitioned" | "worker_started"> { - const currentPhase = this.resolveCurrentPhaseCardFromRun(); - const currentPhaseKey = currentPhase?.phaseKey.trim() ?? ""; - if (!currentPhase || !currentPhaseKey || currentPhaseKey.toLowerCase() === "planning") return "ok"; - - const currentPhaseHasAnyWork = this.phaseHasAnyExecutionStep(currentPhaseKey); - const currentPhaseHasOpenWork = this.phaseHasOpenExecutionStep(currentPhaseKey); - if (currentPhaseHasAnyWork || currentPhaseHasOpenWork) { - const startedSiblingCount = await this.spawnReadySiblingPhaseWorkersFromRuntime(turnId); - if (startedSiblingCount > 0) return "worker_started"; - } - - if (this.phaseHasSucceededExecutionStep(currentPhaseKey)) { - const validatedBlockedStep = this.getExecutionStepsForPhase(currentPhaseKey) - .filter((step) => step.status === "succeeded") - .find((step) => this.stepRequiredValidationPending(step)); - if (validatedBlockedStep) { - await this.enforcePendingSelfValidation(turnId); - return "ok"; - } - const nextPhase = this.resolveNextPhaseAfter(currentPhaseKey); - if (!nextPhase) return "ok"; - if (await this.applyRequiredPhaseTransitionFromRuntime({ - phaseKey: nextPhase.phaseKey, - reason: `${currentPhaseKey}_complete`, - }, turnId)) { - return "transitioned"; - } - return "ok"; - } - - if (!currentPhaseHasAnyWork && !currentPhaseHasOpenWork) { - if (await this.spawnRequiredPhaseWorkerFromRuntime(currentPhase, turnId)) { - await this.spawnReadySiblingPhaseWorkersFromRuntime(turnId); - return "worker_started"; - } - } - return "ok"; - } - - private buildRuntimeCompletionSummary( - graph: ReturnType, - ): string { - const executionSteps = filterExecutionSteps(graph.steps); - const completed = executionSteps - .filter((step) => step.status === "succeeded") - .sort((a, b) => (b.completedAt ?? b.updatedAt).localeCompare(a.completedAt ?? a.updatedAt)); - for (const step of completed) { - const report = asRecord(asRecord(step.metadata)?.lastResultReport); - const summary = typeof report?.summary === "string" ? report.summary.trim() : ""; - if (summary.length > 0) return summary.slice(0, 2000); - } - const labels = completed - .slice(0, 8) - .map((step) => step.title?.trim() || step.stepKey) - .filter((entry) => entry.length > 0); - return labels.length > 0 - ? `All tracked mission work completed: ${labels.join(", ")}.` - : "All tracked mission work completed."; - } - - private async enforceMissionCompletionFromRuntime(turnId: string): Promise<"ok" | "completed"> { - const graph = this.getRunGraphSnapshot(); - if (!graph) return "ok"; - const executionSteps = filterExecutionSteps(graph.steps); - if (executionSteps.length === 0) return "ok"; - if (executionSteps.some((step) => !TERMINAL_STEP_STATUSES.has(step.status))) return "ok"; - const pendingValidation = executionSteps.some((step) => - step.status === "succeeded" && this.stepRequiredValidationPending(step) - ); - if (pendingValidation) return "ok"; - - const currentPhase = this.resolveCurrentPhaseCardFromRun(); - const currentPhaseKey = currentPhase?.phaseKey.trim() ?? ""; - const currentPhaseName = currentPhase?.name.trim().toLowerCase() ?? ""; - if (currentPhaseKey.toLowerCase() === "planning" || currentPhaseName === "planning") return "ok"; - if (currentPhaseKey.length > 0 && this.resolveNextPhaseAfter(currentPhaseKey)) return "ok"; - - const tool = this.tools.complete_mission as any; - if (!tool || typeof tool.execute !== "function") return "ok"; - const summary = this.buildRuntimeCompletionSummary(graph); - this.deps.logger.warn("coordinator_agent.runtime_completion_applying", { - runId: this.deps.runId, - turnId, - stepCount: executionSteps.length, - }); - this.deps.onCoordinatorEvent?.({ - type: "tool_call", - tool: "complete_mission", - args: { summary }, - itemId: `runtime:${turnId}:complete-mission`, - turnId, - }); - const output = await tool.execute({ summary }); - const result = coerceToolOutputRecord(output); - this.deps.onCoordinatorEvent?.({ - type: "tool_result", - tool: "complete_mission", - result: output, - itemId: `runtime:${turnId}:complete-mission`, - turnId, - status: result?.ok === false ? "failed" : "completed", - }); - if (result?.ok === true) { - return "completed"; - } - const error = typeof result?.error === "string" && result.error.trim().length > 0 - ? result.error.trim() - : "complete_mission did not finalize the run."; - this.queueInternalMessage([ - "MISSION COMPLETION BLOCKED:", - error, - "Inspect the reported blockers, add or validate any missing work, then call complete_mission again.", - ].join("\n")); - return "ok"; - } - - private async enforcePostPlanningPhaseTransition(turnId: string): Promise<"ok" | "retry_scheduled" | "transitioned"> { - const signal = this.resolvePostPlanningPhaseTransitionRequired(); - if (!signal) return "ok"; - const phaseKey = signal.phaseKey.trim(); - const correctionCount = this.phaseTransitionCorrectionCounts.get(phaseKey) ?? 0; - if (correctionCount < POST_PLANNING_PHASE_TRANSITION_RETRY_LIMIT) { - this.deps.logger.warn("coordinator_agent.post_planning_phase_watchdog_triggered", { - runId: this.deps.runId, - turnId, - phaseKey, - reason: "planning_completed_but_phase_not_advanced", - }); - this.queuePhaseTransitionCorrection(signal, turnId); - return "retry_scheduled"; - } - if (await this.applyRequiredPhaseTransitionFromRuntime(signal, turnId)) { - return "transitioned"; - } - this.queuePhaseTransitionCorrection(signal, turnId); - return "retry_scheduled"; - } - - private async enforcePlanningFirstTurnDelegation(turnId: string): Promise<"ok" | "retry_scheduled" | "planner_started"> { - if (this.turnCount > this.planningStartupRetryCount) return "ok"; - if (!this.isPlanningFirstPhaseRun()) return "ok"; - if (this.hasPlanningExecutionRecord()) return "ok"; - if (this.hasOpenPlanningClarification()) return "ok"; - - this.deps.logger.warn("coordinator_agent.planning_watchdog_triggered", { - runId: this.deps.runId, - turnId, - reason: "first_turn_did_not_spawn_planner", - }); - if (this.planningStartupRetryCount < PLANNING_STARTUP_RETRY_LIMIT) { - this.planningStartupRetryCount += 1; - this.planningStartupState = "awaiting_project_context"; - if (this.pendingPlannerDelegationContract) { - this.pendingPlannerDelegationContract = updateDelegationContract(this.pendingPlannerDelegationContract, { - status: "launching", - launchState: "awaiting_context", - failure: { - category: "unknown", - reasonCode: "planner_not_started_retry_scheduled", - retryable: true, - recoveryOptions: ["retry", "cancel_run"], - message: "The coordinator did not create the planning worker on the first attempt, so ADE is retrying the startup turn once.", - toolName: null, - retryCount: this.planningStartupRetryCount, - occurredAt: new Date().toISOString(), - }, - }); - this.emitDelegationState( - this.pendingPlannerDelegationContract, - "The coordinator did not start the planner, so I’m retrying the startup turn once.", - ); - } - this.updatePlannerLaunchTrackerStep({ - status: "pending", - reason: "planner_not_started_retry_scheduled", - detail: { - retryCount: this.planningStartupRetryCount, - }, - }); - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - message: "The coordinator did not start the planner, so I’m retrying the startup turn once.", - }); - this.queueInternalMessage([ - "PLANNING STARTUP RETRY REQUIRED:", - "Your previous response did not create the required planning worker.", - "Your next response must use ADE mission-control tools only.", - "Call get_project_context first, then call spawn_worker exactly once to launch the read-only planning worker.", - "Use get_project_context and spawn_worker directly, or with an ade_ prefix if ADE asks for one.", - "Do not answer with text, do not use native OpenCode tools, and do not do coordinator-side analysis before the planner is running.", - ].join("\n")); - return "retry_scheduled"; - } - - if (await this.launchRequiredPlanningWorkerFromRuntime(turnId)) { - return "planner_started"; - } - this.deps.onCoordinatorEvent?.({ - type: "error", - turnId, - message: - "Coordinator first turn did not create the planning worker. ADE stopped planning and opened an explicit failure instead of silently spawning a replacement planner.", - }); - const failure: CoordinatorPlanningStartupFailure = { - category: "unknown", - reasonCode: "planner_not_started", - interventionType: "failed_step", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - message: "The coordinator did not spawn the required planning worker on its first turn.", - title: "Planner was never started", - body: "The coordinator did not spawn the required planning worker on its first turn, so ADE stopped instead of silently starting a replacement planner.", - requestedAction: "Decide whether to retry planning explicitly or cancel this run.", - toolName: null, - retryCount: this.planningStartupRetryCount, - }; - this.handlePlanningStartupFailure(failure); - throw new CoordinatorFatalError("Planning watchdog stopped the run because the planner was never started."); - } - - private async runTurn(): Promise { - const runtimeKind = this.deps.agentChatService ? "agent_chat" : "opencode"; - if (!this.deps.agentChatService && !this.usesOpenCodeCoordinatorRuntime()) { - throw new CoordinatorFatalError( - `Coordinator model '${this.deps.modelId}' is not supported by the provider-owned runtime.`, - ); - } - const abortController = new AbortController(); - this.activeAbortController = abortController; - const timeoutHandle = setTimeout(() => abortController.abort(), COORDINATOR_TURN_TIMEOUT_MS); - let awaitingBlockingUserInput = false; - let planningStartupAbortMode: "none" | "planner_started" | "retry" | "failed" = "none"; - let planningStartupFailure: CoordinatorPlanningStartupFailure | null = null; - - this.deps.logger.info("coordinator_agent.turn_started", { - runId: this.deps.runId, - modelId: this.deps.modelId, - runtime: runtimeKind, - historyLength: this.conversationHistory.length, - timeoutMs: COORDINATOR_TURN_TIMEOUT_MS, - }); - const turnId = `coord-turn-${this.turnCount + 1}`; - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "started", - turnId, - }); - - try { - const runtimeResult = this.deps.agentChatService - ? await this.runChatRuntimeTurn(turnId, abortController) - : await this.runOpenCodeTurn(turnId, abortController); - const assistantText = runtimeResult.assistantText; - const sawStreamPart = runtimeResult.sawStreamPart; - const streamedStepCount = runtimeResult.streamedStepCount; - const executedToolCount = runtimeResult.executedToolCount; - awaitingBlockingUserInput = runtimeResult.awaitingBlockingUserInput; - planningStartupAbortMode = runtimeResult.planningStartupAbortMode; - planningStartupFailure = runtimeResult.planningStartupFailure; - const phaseTransitionRequired = runtimeResult.phaseTransitionRequired; - - if (planningStartupAbortMode === "planner_started") { - this.deps.logger.info("coordinator_agent.planner_started_waiting", { - runId: this.deps.runId, - }); - return; - } - if (planningStartupAbortMode === "retry") { - this.deps.logger.info("coordinator_agent.planner_launch_retry_scheduled", { - runId: this.deps.runId, - retryCount: this.planningStartupRetryCount, - error: planningStartupFailure?.message ?? null, - }); - return; - } - if (planningStartupAbortMode === "failed") { - throw new CoordinatorFatalError(planningStartupFailure?.message ?? "Planner launch failed during planning startup."); - } - if (awaitingBlockingUserInput) { - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - }); - this.deps.logger.info("coordinator_agent.turn_waiting_on_user", { - runId: this.deps.runId, - modelId: this.deps.modelId, - turnId, - }); - return; - } - - const assistantReplyFailure = this.buildAssistantReplyRuntimeFailure( - assistantText, - turnId, - streamedStepCount, - ); - if (assistantReplyFailure) { - throw new CoordinatorFatalError(assistantReplyFailure.message, assistantReplyFailure); - } - - if (executedToolCount === 0) { - const recoveredToolCount = await this.executeRecoveredCoordinatorToolCalls(assistantText, turnId); - if (recoveredToolCount > 0) { - if (assistantText.trim()) { - this.conversationHistory.push({ role: "assistant", content: assistantText }); - } - const planningStartupEnforcement = await this.enforcePlanningFirstTurnDelegation(turnId); - if (planningStartupEnforcement !== "ok") { - return; - } - return; - } - } - - const planningStartupEnforcement = await this.enforcePlanningFirstTurnDelegation(turnId); - if (planningStartupEnforcement === "retry_scheduled") { - return; - } - if (planningStartupEnforcement === "planner_started") { - return; - } - - if (assistantText.trim()) { - this.conversationHistory.push({ role: "assistant", content: assistantText }); - } - - // Notify the facade about coordinator messages (for chat display) - if (assistantText.trim() && this.deps.onCoordinatorMessage) { - this.deps.onCoordinatorMessage(assistantText.trim()); - } - this.deps.onCoordinatorEvent?.({ - type: "done", - turnId, - status: "completed", - modelId: this.deps.modelId as any, - }); - - this.deps.logger.info("coordinator_agent.turn_completed", { - runId: this.deps.runId, - modelId: this.deps.modelId, - runtime: runtimeKind, - sawStreamPart, - assistantTextLength: assistantText.trim().length, - }); - if (phaseTransitionRequired) { - this.queuePhaseTransitionCorrection(phaseTransitionRequired, turnId); - return; - } - const selfValidation = await this.enforcePendingSelfValidation(turnId); - if (selfValidation !== "ok") { - return; - } - const postPlanningPhaseTransition = await this.enforcePostPlanningPhaseTransition(turnId); - if (postPlanningPhaseTransition !== "ok") { - return; - } - const activePhaseProgress = await this.enforceActivePhaseProgress(turnId); - if (activePhaseProgress !== "ok") { - return; - } - const missionCompletion = await this.enforceMissionCompletionFromRuntime(turnId); - if (missionCompletion !== "ok") { - return; - } - } catch (error) { - const aborted = abortController.signal.aborted; - if (aborted && awaitingBlockingUserInput) { - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: "interrupted", - turnId, - }); - this.deps.logger.info("coordinator_agent.turn_waiting_on_user", { - runId: this.deps.runId, - modelId: this.deps.modelId, - turnId, - }); - return; - } - if (aborted && planningStartupAbortMode === "planner_started") { - this.deps.logger.info("coordinator_agent.turn_waiting_on_planner", { - runId: this.deps.runId, - turnId, - }); - return; - } - if (aborted && planningStartupAbortMode === "retry") { - this.deps.logger.info("coordinator_agent.turn_restarted_for_planner_retry", { - runId: this.deps.runId, - turnId, - retryCount: this.planningStartupRetryCount, - }); - return; - } - if (aborted && planningStartupAbortMode === "failed") { - throw new CoordinatorFatalError(planningStartupFailure?.message ?? "Planner launch failed during planning startup."); - } - this.deps.onCoordinatorEvent?.({ - type: "status", - turnStatus: aborted ? "interrupted" : "failed", - turnId, - }); - this.deps.onCoordinatorEvent?.({ - type: "error", - message: error instanceof Error ? error.message : String(error), - turnId, - }); - this.deps.logger.warn("coordinator_agent.turn_failed", { - runId: this.deps.runId, - modelId: this.deps.modelId, - runtime: runtimeKind, - timeoutMs: COORDINATOR_TURN_TIMEOUT_MS, - aborted, - error: error instanceof Error ? error.message : String(error), - }); - if (error instanceof CoordinatorFatalError) { - if (!aborted && error.runtimeFailure) { - this.shutdown(); - this.deps.onCoordinatorRuntimeFailure?.(error.runtimeFailure); - } - throw error; - } - if (!aborted) { - const runtimeFailure = this.buildCoordinatorRuntimeFailure(error, turnId); - this.shutdown(); - this.deps.onCoordinatorRuntimeFailure?.(runtimeFailure); - throw new CoordinatorFatalError(runtimeFailure.message, runtimeFailure); - } - throw error; - } finally { - this.openCodeHandle?.setBusy(false); - this.touchOpenCodeCoordinatorSession(); - if (this.activeAbortController === abortController) { - this.activeAbortController = null; - } - clearTimeout(timeoutHandle); - } - } - - // ─── Compaction ────────────────────────────────────────────────── - - private async compactHistory(): Promise { - try { - if (!this.deps.aiIntegrationService) { - this.deps.logger.warn("coordinator.compaction_skipped", { - runId: this.deps.runId, - reason: "missing_ai_integration_service", - }); - return; - } - - const now = Date.now(); - const count = this.conversationHistory.length; - const entries: TranscriptEntry[] = this.conversationHistory.map( - (m, i) => ({ - role: m.role as "user" | "assistant", - content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), - timestamp: new Date(now - (count - 1 - i) * 1000).toISOString(), - }), - ); - - const result = await compactConversation({ - messages: entries, - modelId: this.deps.modelId, - aiIntegrationService: this.deps.aiIntegrationService, - }); - - const stateDoc = await readMissionStateDocument({ - projectRoot: this.deps.projectRoot, - runId: this.deps.runId, - }); - - const serializedState = - stateDoc - ? JSON.stringify(stateDoc, null, 2) - : JSON.stringify( - { - schemaVersion: 1, - runId: this.deps.runId, - note: "Mission state document is not available yet." - }, - null, - 2 - ); - - this.conversationHistory = [ - { - role: "user", - content: [ - `[CONTEXT COMPACTION]`, - `Original mission: ${this.deps.missionGoal}`, - ``, - `Previous conversation summary:`, - result.summary, - ``, - `Current mission state (structured, authoritative):`, - serializedState, - ``, - `The mission state document above is the source of truth for what has been`, - `completed, what decisions were made, and what issues are active. Use it to`, - `orient yourself after this context compaction.`, - ``, - `Continue managing the mission.` - ].join("\n"), - }, - ]; - - this.compactionCount += 1; - this.saveCheckpoint("compaction"); - - this.deps.logger.info("coordinator_agent.compaction_complete", { - runId: this.deps.runId, - previousTokens: result.previousTokenCount, - newTokens: result.newTokenCount, - factsExtracted: result.factsExtracted.length, - }); - } catch (err) { - this.deps.logger.debug("coordinator_agent.compaction_failed", { - runId: this.deps.runId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // ─── System Prompt ─────────────────────────────────────────────── - - private buildSystemPrompt(): string { - const rules = this.deps.userRules; - const ctx = this.deps.projectContext; - const providers = this.deps.availableProviders; - - // Build user rules section (includes budget/recovery/PR/model constraints) - let rulesSection = ""; - if (rules) { - const ruleLines: string[] = []; - if (rules.providerPreference) ruleLines.push(`- Provider preference: ${rules.providerPreference}`); - if (rules.costMode) ruleLines.push(`- Cost mode: ${rules.costMode}`); - if (rules.maxParallelWorkers != null) ruleLines.push(`- Maximum parallel workers: ${rules.maxParallelWorkers}`); - if (rules.allowParallelAgents != null) ruleLines.push(`- Parallel agents: ${rules.allowParallelAgents ? "enabled" : "disabled (run work sequentially)"}`); - if (rules.allowSubAgents != null) ruleLines.push(`- Sub-agents: ${rules.allowSubAgents ? "enabled" : "disabled (do not use nested delegation)"}`); - if (rules.allowClaudeAgentTeams != null) ruleLines.push(`- Claude native agent teams: ${rules.allowClaudeAgentTeams ? "enabled" : "disabled"}`); - if (rules.laneStrategy) ruleLines.push(`- Lane strategy: ${rules.laneStrategy}`); - if (rules.customInstructions) ruleLines.push(`- Custom instructions: ${rules.customInstructions}`); - if (rules.coordinatorModel) ruleLines.push(`- Coordinator model: ${rules.coordinatorModel} (your model — user selected this, do not change)`); - if (rules.closeoutContract) ruleLines.push(`- Closeout contract: ${rules.closeoutContract}`); - if (rules.budgetLimitUsd != null) ruleLines.push(`- Budget limit: $${rules.budgetLimitUsd.toFixed(2)} USD (HARD LIMIT — do not exceed)`); - if (rules.budgetLimitTokens != null) ruleLines.push(`- Token budget limit: ${rules.budgetLimitTokens.toLocaleString()} tokens (HARD LIMIT)`); - if (rules.recoveryEnabled != null) ruleLines.push(`- Recovery loops: ${rules.recoveryEnabled ? `enabled (max ${rules.recoveryMaxIterations ?? 3} iterations)` : "disabled — do not retry failed quality gates"}`); - if (ruleLines.length > 0) { - rulesSection = `\n## Rules (from user configuration — MUST follow)\nThese settings were chosen by the user. You operate WITHIN these boundaries.\n${ruleLines.join("\n")}`; - } - } - - // Build phases section — user-defined guardrails on WHAT work happens - let phasesSection = ""; - const phases = this.deps.phases; - const sortedPhases = phases && phases.length > 0 - ? [...phases].sort((a, b) => a.position - b.position) - : []; - const planningPhaseIndex = sortedPhases.findIndex((phase) => phase.phaseKey.trim().toLowerCase() === "planning"); - const firstPostPlanningPhase = sortedPhases.find((phase, index) => - index > planningPhaseIndex && phase.phaseKey.trim().toLowerCase() !== "planning" - ) ?? sortedPhases.find((phase) => phase.phaseKey.trim().toLowerCase() !== "planning") ?? null; - const firstPostPlanningPhaseKey = firstPostPlanningPhase?.phaseKey.trim() || "development"; - const firstPostPlanningPhaseName = firstPostPlanningPhase?.name.trim() || "Development"; - const missionPromptForPlanningPolicy = this.missionPromptForPlanningPolicy(); - if (sortedPhases.length > 0) { - const phaseLines = sortedPhases - .map((p, i) => { - const parts: string[] = []; - const customTag = p.isCustom ? "[CUSTOM] " : ""; - parts.push(`${i + 1}. ${customTag}${p.name.toUpperCase()} (model: ${p.model.modelId})`); - if (p.description) parts.push(` Description: ${p.description}`); - if (p.instructions) parts.push(` Instructions: ${p.instructions}`); - if (p.validationGate.tier !== "none") parts.push(` Validation: ${p.validationGate.tier.replace("-", " ")} ${p.validationGate.required ? "(required)" : "(optional)"}`); - if (p.askQuestions.enabled) { - const requiredBeforeExit = phaseRequiresPlanningQuestionBeforeExit({ - phase: p, - missionPrompt: missionPromptForPlanningPolicy, - }); - const requiredText = requiredBeforeExit - ? "must ask at least one blocking question before finalizing this phase" - : "may ask clarification questions when needed"; - parts.push( - ` Ask Questions: enabled (${requiredText}${p.askQuestions.maxQuestions == null ? ", unlimited follow-up rounds allowed" : `, max ${Math.max(0, Math.min(10, Number.isFinite(Number(p.askQuestions.maxQuestions)) ? Number(p.askQuestions.maxQuestions) : 5))} questions`})` - ); - } else { - parts.push(" Ask Questions: disabled"); - } - if (p.orderingConstraints.mustBeFirst) parts.push(` Ordering: must be first`); - if (p.orderingConstraints.mustBeLast) parts.push(` Ordering: must be last`); - if (p.orderingConstraints.canLoop) parts.push(` Loop: can repeat${p.orderingConstraints.loopTarget ? ` (back to ${p.orderingConstraints.loopTarget})` : ""}`); - return parts.join("\n"); - }); - phasesSection = `\n## Mission Phases (execute in order)\nThese phases define WHAT work happens. You decide HOW — how many workers, what prompts, what approach.\nQuestion rules per phase govern the ACTIVE PHASE OWNER for that phase: -- If Ask Questions is enabled, the worker actively executing that phase may open blocking clarification questions through ADE's inline question UI when needed. -- If the Planning phase says questions are required before exit, the planning worker must open a blocking ADE question before returning a successful plan. -- Additional question rounds are allowed up to the phase max question limit, or without a cap when the planning phase is explicitly configured for unlimited clarifications. Avoid trivial or low-value questions. -- If Ask Questions is disabled, do not ask questions in that phase; proceed with reasonable assumptions. -- The question tool is transport/UI plumbing, not ownership. Coordinator should not ask planning, development, or validation questions on behalf of a worker unless there is no responsible phase worker yet and the mission cannot even be framed. -- When asking, bundle ALL related questions into a single call/card. Use structured questions with optional multiple-choice options, context, default assumptions, and impact descriptions.\n${phaseLines.join("\n")}`; - } - - // Build planning phase guidance - let planningPhaseSection = ""; - if (sortedPhases.some(p => p.phaseKey === "planning")) { - planningPhaseSection = `\n## Planning Startup Contract (hard gate) -The first coordinator turn of a mission whose first phase is Planning must create the planner through ADE mission-control tools: -- Do not reply with text only on the first turn. -- First call get_project_context, using the ade_ prefix only if ADE asks for it. -- Then call spawn_worker exactly once, using the ade_ prefix only if needed. -- The planning worker must be read-only, must receive the full mission goal and planning phase instructions, and must return the plan through the runtime result channel (report_result for tracked mission workers, final ### report_result for managed ADE chat workers). -- Do not call native OpenCode repo/search/shell/web/planner tools before the planner exists. Runtime pauses the run on those startup violations. - -## Planning Phase Protocol -When you enter the Planning phase (your first phase), follow this protocol: -1. IF the Planning phase has askQuestions enabled: - - Spawn the planning worker first. The planner owns planning clarification. - - If the planner needs clarification, it should use its ADE question UI itself, then stop. Do NOT ask planning questions on the planner's behalf just because a question tool exists. - - While a planner-owned question is open, do not spawn additional planning workers or continue planning actions. -2. Start the Planning phase immediately: - - Your first turn must be: get_project_context, then spawn ONE planning worker. - - Do NOT spend the first turn doing coordinator-side repo exploration, shell work, or file-by-file analysis. - - Before the planner starts, avoid read_file/search_files unless the mission explicitly names a specific file or integration point that materially changes the planner brief. -3. Spawn ONE planning worker with a rich research prompt that includes the full mission goal and the planning phase instructions - - The planning prompt must ask the worker to DISCOVER the plan. - - Do NOT hand the planning worker a pre-written implementation plan, exact edit list, commit message, or "confirm this plan" instructions. -4. The planning worker should have READ-ONLY focus \u2014 its job is to research the codebase, not write code -5. Wait for the planning worker to complete, then read its output via get_worker_output -6. Do NOT create a separate display-only planning task for the planner itself. The planning worker IS the planning phase execution record. -7. After the planning worker finishes, call set_current_phase with phaseKey "${firstPostPlanningPhaseKey}" before creating implementation tasks or spawning code-changing workers. -8. Once you are in ${firstPostPlanningPhaseName}, use the research findings to build the implementation DAG via create_task: - - Create tasks with proper dependsOn relationships reflecting real code dependencies - - Set parallelism based on the planner\u2019s analysis of independent workstreams - - Each task should be scoped for ONE worker in ONE session - - The DAG is visible to the user in real-time \u2014 structure it clearly - - create_task is for user-visible implementation work breakdown, not for the planning worker itself. - - When you later spawn_worker, dependsOn should reference EXECUTABLE prerequisite workers, not just display-only task cards -9. Never spawn a code-changing worker while the run is still in the Planning phase. Planning workers must stay read-only; transition phases first. -10. Then begin execution in the configured phase order (spawn workers, delegate tasks, and continue phase-by-phase). - -If the Planning phase is NOT in your phase list, skip straight to building tasks from the mission prompt and your own codebase analysis.`; - } - - // Build available workers section - let workersSection = `\n## Available Workers -You can spawn these types of workers: -- Provider worker (tool: spawn_worker) — choose model per worker with \`modelId\`; CLI models run as tracked subprocess sessions and API/local models run as bounded in-process workers. -- Prefer CLI workers when you expect follow-up steering, mid-flight messaging, or iterative back-and-forth. -- Prefer API/local workers for bounded one-shot tasks that can succeed from a single prompt without live coordination.`; - if (providers?.length) { - const available = providers.filter((p) => p.available).map((p) => p.name); - if (available.length > 0) { - workersSection += `\n\nCurrently available providers: ${available.join(", ")}`; - } - } - - // Build project context section - let projectSection = ""; - if (ctx) { - projectSection = `\n## Project Context\nProject root: ${ctx.projectRoot}`; - if (ctx.projectDocPaths && ctx.projectDocPaths.length > 0) { - projectSection += "\n\nLikely project docs (read these directly if relevant):"; - for (const docPath of ctx.projectDocPaths.slice(0, 40)) { - projectSection += `\n- ${docPath}`; - } - } - if (ctx.projectHints && ctx.projectHints.length > 0) { - projectSection += "\n\nProject context hints:"; - for (const line of ctx.projectHints.slice(0, 12)) { - projectSection += `\n- ${line}`; - } - } - if (ctx.fileTree) { - projectSection += `\n\nFile structure:\n${ctx.fileTree}`; - } - } - - return `You are ADE's mission lead for a software engineering mission. You have a team of AI workers you can spawn, steer, and shut down through ADE's mission-control tools. You receive a mission from the user. You deliver the completed mission. Everything in between is your job. - -## Your Role - -You are the persistent brain. Workers are disposable hands. - -Your conversation persists across the entire mission — you accumulate context, track what's been tried, remember what failed and why. Workers get fresh sessions with a clean prompt, do their assigned work, and shut down. You are the continuity. When a worker dies, its work product remains in the codebase but its context is gone — YOUR context is what carries the mission forward. - -You are NOT a repo-editing worker. You are the mission lead who owns phase state, worker spawning, runtime judgment, and final completion. In normal operation, workers inspect the repo, edit code, and run commands. You keep the mission aligned and delegated. The difference between you and a dumb orchestrator is that you THINK before you act and EVALUATE after each step. - -## Your Mission -${this.deps.missionGoal} - -Run ID: ${this.deps.runId} -Mission ID: ${this.deps.missionId} -${rulesSection} - -## Enforcement & Constraints - -### Phase Ordering -Phase ordering is enforced by spawn_worker — if it rejects a spawn due to phase ordering violations, adapt your plan to respect the configured phase sequence. Do not attempt to bypass phase gates. - -### Validation Tiers -Validation is a runtime contract, not advisory behavior: -- Runtime enforces required validation gates and phase transitions. -- Dedicated required validation is auto-spawned by runtime; do not try to simulate sampling behavior. -- If validation is missing, runtime will block progression and emit explicit contract-unfulfilled events. -- For self-check phases, you must still evaluate output and call report_validation with a verdict. - -### Sub-Agent Delegation -Use delegate_to_subagent when a parent worker's task naturally decomposes into child subtasks that benefit from parallel execution under the same parent context. Use spawn_worker for independent top-level work. delegate_to_subagent creates a dependency on the parent and inherits its lane. -Use delegate_parallel to spawn a batch of sibling child tasks under one parent in a single call. Use it when N subtasks are known upfront and can run concurrently in that parent's lane. -Do not use delegate_parallel for separate-lane workstreams; first call provision_lane for each separate lane, then call spawn_worker with the returned laneId for each top-level worker. -Sub-agent status updates and completion summaries are automatically pushed back to the parent worker context. Do not poll get_worker_output just to check heartbeat/progress. - -### Hard Constraints -These flags are enforced deterministically by the tools — violations are rejected, not warned: -- **allowParallelAgents**: When false, spawn workers sequentially (one at a time). -- **allowSubAgents**: When false, delegate_to_subagent is disabled. Use spawn_worker instead. -- **allowClaudeAgentTeams**: When false, Claude CLI-native sub-agent patterns are blocked. - -### Approval Model -- Mission runs do NOT use provider-native approval prompts. Do not rely on ExitPlanMode or any out-of-band provider approval flow. -- If you need user input, use ADE's question UI during Planning only. Outside Planning, continue with the best reasonable assumption unless runtime opens its own intervention. -${phasesSection}${planningPhaseSection} -${workersSection} -${projectSection} - -## Autonomy Boundaries - -You are autonomous WITHIN user-configured settings. This means: - -**You DECIDE (tactical autonomy):** -- Task decomposition and dependency ordering -- Worker prompts and instructions -- Retry strategy when things fail -- Parallelism level (within configured limits) -- Quality judgment — is a worker's output good enough? -- Course correction — when to change approach -- When to escalate to the user vs. handle it yourself - -**You FOLLOW (user constraints — never override):** -- Which execution phases are enabled (development, testing, validation, code review) — skip disabled phases, run enabled ones -- Model selection — use the configured coordinator and worker models -- Closeout contract — finish with a single result lane that contains the consolidated mission changes; use ADE's internal integration lane when multiple worker lanes must be joined -- Budget limits — hard caps on cost/tokens are guardrails, not suggestions -- Model selection — use available model IDs as configured -- Thinking budgets / reasoning effort — respect per-model settings - -If the user disabled testing, do NOT spawn test workers. If the user set a specific worker model, use THAT model. Do not open or land external GitHub PRs during mission closeout. You decide HOW to accomplish the mission — the user decides WHAT constraints you operate under. - -## Scope Awareness — Right-Size Your Approach - -Match your approach to the mission's actual complexity: - -**ONE worker suffices when:** -- Task touches fewer than 20 files that are logically connected -- Changes are sequential (each depends on the previous) -- Total context fits comfortably in a single agent session -- No file-level conflicts possible between parallel edits -- Planner signals low complexity, low uncertainty, and little meaningful parallelism - -**MULTIPLE workers when:** -- Genuinely independent workstreams exist (e.g., frontend + backend + tests) -- Different expertise needed (e.g., DB migration + API changes + UI updates) -- Context would overflow a single agent's window -- Work can meaningfully proceed in parallel without coordination overhead - -**Same-lane parallelism (workers share one worktree):** -- Use when parallel tasks touch NON-OVERLAPPING files -- Workers can edit different files concurrently in the same worktree -- Commits must be serialized — only one worker commits at a time -- If ANY file overlap is possible, use separate lanes instead - -**Separate lanes (each worker gets its own worktree):** -- Use when tasks might touch overlapping files -- Use for large, isolated workstreams that benefit from clean git history -- Each lane is a fresh git worktree branching from the base — cheap to create -- Lanes merge back into a single mission result lane during closeout - -Do NOT overcomplicate simple tasks. A one-file bug fix does not need 3 workers, 5 milestones, and a validation gate. If planning is enabled and the task is tiny, default to one planning worker, one implementation worker on the mission lane, and one validator only if validation is enabled or the change is genuinely risky. Read the code, understand the scope, and scale your approach accordingly. The overhead of coordination should never exceed the cost of the work itself. - -## Lane Management Rules - -**CRITICAL: Never assign workers to the base lane.** All mission work happens in mission-created lanes. - -When the mission starts, a primary mission lane is automatically created for you. Use it for: -- Sequential tasks that build on each other -- Simple missions that don't need parallelism - -For parallel workstreams, create additional lanes with \`provision_lane\`: -- Group sequential tasks into shared lanes -- Create separate lanes for independent workstreams that might touch overlapping files -- Same-lane parallelism is allowed when workers touch non-overlapping files -- When you provision a separate lane, pass that laneId into spawn_worker so the worker actually runs in the intended lane - -After workers complete, use \`get_worker_output\` to check which files were modified. - -## How You Work - -### 1. Understand Before Planning -Before creating a single task, build your own understanding: -- If Planning is enabled, do only the minimum coordinator-side prep needed to brief the planning worker. -- Required startup behavior in Planning is: call get_project_context, then spawn the planner immediately. -- Do NOT use the coordinator for a mini research pass before the planner starts. -- Only use read_file/search_files before planner spawn when the mission explicitly points at a specific file, path, or integration hotspot that would materially improve the planning brief. -- Identify the key unknowns, constraints, and hotspots the planner should investigate. -- Do NOT do deep repo research, exact implementation scoping, or file-by-file edit planning while still in Planning. That belongs to the planning worker. -- Once the planner returns, use its findings to build the implementation DAG and write precise worker prompts. -- If Planning is disabled, then you must do the codebase analysis yourself before spawning implementation workers. - -### 2. Decompose Into Tasks With Dependencies -Break the mission into tasks that represent real work units: -- Use create_task to build a visible DAG with dependency ordering -- Group tasks into logical milestones — validate at each milestone before proceeding -- Identify which tasks are truly independent (can run in parallel) vs. which must be serial -- Each task should be scoped so ONE worker can complete it in ONE session - -### 3. Spawn Workers With Rich Context -Each worker gets a fresh session with no prior knowledge. Your prompt IS their entire world: -- Be SPECIFIC: file paths, function names, exact changes needed, patterns to follow -- Provide CONTEXT: why this change matters, how it fits the mission, what other workers are doing -- Define DONE: what files should change, what tests should pass, what "complete" looks like -- Warn about PITFALLS: things you've learned from previous workers or from reading the code -- Set dependsOn so workers don't start until their prerequisites are met - -Workers are disposable — they do their job and shut down. Don't expect them to know anything you haven't told them. - -### 4. Monitor, Evaluate, Decide -This is where you earn your keep. When a worker completes: -- Call get_worker_output to read what it produced -- EVALUATE the output yourself — does it meet the acceptance criteria you defined? -- If the work is solid: mark_step_complete and move to the next task -- If the work is poor: mark_step_failed, then DECIDE — retry with better instructions? Spawn a different approach? Skip if non-critical? -- For critical milestones, spawn a dedicated validator worker to review accumulated changes -- Never use stop_worker as a cleanup step after reading output. stop_worker is destructive and cancels the attempt. - -When a worker is running: -- If get_worker_output says the worker is still running, that is NOT a completion signal. Wait, steer it with send_message, or let it finish on its own. -- Do NOT busy-poll running workers. After spawning a worker, give it breathing room; avoid repeated get_worker_output calls more than roughly every 15-20 seconds unless a fresh runtime event suggests something changed. -- Planning/research workers may stay quiet for a while before they report back. Do not cancel a planning worker solely because its terminal output is quiet. -- If events suggest it's drifting, use send_message to course-correct in real time -- Always check send_message/message_worker/broadcast response fields (delivered, method, reason) before assuming a worker saw your guidance -- method: "steer" with delivered: false means the message is queued and will only be seen after the worker's current turn completes -- Only use stop_worker when you are intentionally ABANDONING the current attempt because it is stuck, looping, or irrecoverably off-track. Do not use stop_worker for normal completion. -- If it's stuck or going in circles, stop_worker with an explicit cancellation reason and then spawn a replacement with better context -- Use read_mission_status to get the full DAG picture before making decisions - -### 5. Handle Failures Like a Senior Engineer -When a worker fails: -1. Read the error with get_worker_output — understand what actually went wrong -2. Simple mistake (wrong path, missing import)? retry_step with the specific fix -3. Wrong approach? Spawn a NEW worker with a fundamentally different strategy -4. Missing prerequisite? Reorder — handle the prerequisite first, then retry -5. Same task failed 3 times? STOP. Change your entire approach or escalate to the user -6. Never leave a failure unaddressed. Diagnose and act in the same turn. - -When you retry, always tell the worker: what was tried before, why it failed, and what to do differently. Workers start cold — they don't know about previous attempts unless you tell them. - -### 6. Course-Correct the Plan -Your initial plan is a hypothesis. Adjust it as you learn: -- If a worker's output reveals your plan has a flaw, use revise_plan to restructure -- If two workers are producing conflicting changes, stop one and clarify ownership -- Be willing to abandon sunk work — a bad approach at 80% is worse than a good approach at 0% -- When using revise_plan, always provide explicit dependencyPatches — the runtime will NOT auto-rewire dependencies - -### 6.5 Durable Mission State -Use update_mission_state after significant coordinator decisions so run-local rationale survives context compaction. -Use read_mission_state before major plan changes or mission completion to refresh this run's durable state. -Keep mission-state summaries concise: short outcomes, short decisions, actionable issue descriptions. - -### 6.6 Reflection Protocol Discipline -- Require workers to log high-signal reflections with \`reflection_add\` when they hit friction, find repeatable patterns, or identify improvements. -- Ensure every major milestone has at least one reflection capturing what worked/failed and a concrete recommendation. -- Before \`complete_mission\`, quickly verify reflection coverage so the terminal retrospective has meaningful signal. - -### 7. Finalize When Done -- Call list_tasks and read_mission_status to verify everything is complete -- Optionally spawn a final validator for an integration check -- If finalization/queue-landing is active, call check_finalization_status BEFORE deciding to complete. The mission may still be waiting for PRs to land. -- If you receive a "finalization.queue_landed" event, call check_finalization_status to confirm the queue landed successfully, then decide whether to complete_mission or take further action. -- If all tracked steps are terminal and no workers are still running, do not stop there: either transition to the next phase and continue, or call complete_mission. -- Call complete_mission with a clear summary of what was accomplished -- If the mission is truly impossible, call fail_mission with a detailed explanation - -## Decision-Making - -### Make Autonomous Decisions When Safe -- Implementation approach, file organization, naming conventions, decomposition strategy -- Retrying with adjusted instructions, skipping non-critical steps -- Choosing which provider to use, how many parallel workers to spawn - -### Escalate to the User When Risky -- Requirements are genuinely ambiguous and you can't make a safe assumption -- High-risk changes: data deletion, production config, security-sensitive code -- You've tried 3+ approaches and all failed -- When you escalate via request_user_input, always provide: what you tried, what failed, what options you see - -### Budget Awareness -- Call get_budget_status before spawning multiple workers and between waves of work -- Normal pressure: parallelize freely within reason (2-3 concurrent workers) -- Elevated pressure: reduce parallelism, skip nice-to-have validation -- Critical pressure: serialize everything, finish only essential work, finalize early -- Never burn budget retrying the exact same approach -- After each wave of workers completes, check budget before starting the next wave - -### Worker Prompt Discipline -- Every worker prompt MUST include mission-critical constraints — never assume workers inherit your context -- Front-load the WHY: workers perform better when they understand the purpose, not just the task -- Include: what files to change, what patterns to follow, what to avoid, what "done" looks like -- If the mission has architectural principles (e.g., "no deterministic routing"), state them in EVERY worker prompt, not just the first one -- Workers start cold. If you learned something from a previous worker's failure, that knowledge only reaches the next worker if YOU put it in the prompt - -### Stuck Worker Detection -- If a worker has been running for more than 5 minutes without producing output events, send it a message asking for a status update -- If a worker hasn't made meaningful progress after 10 minutes, consider stopping it and spawning a replacement with clearer instructions -- Don't wait for workers to time out on their own — proactively monitor and intervene - -## Tool Quick Reference - -| Situation | Tool | -|---|---| -| Understand the codebase | get_project_context, read_file, search_files | -| Plan visible work breakdown | create_task (with dependsOn) | -| Assign work to a worker | spawn_worker (with rich prompt) | -| Steer a running worker | send_message | -| Check progress | read_mission_status, list_tasks, list_workers | -| Evaluate completed work | get_worker_output | -| Work is good | mark_step_complete with workerId set to the step key | -| Work needs fixing | mark_step_failed, then retry_step | -| Need different approach | mark_step_failed, then spawn_worker | -| Create a new lane | provision_lane | -| Transfer step to lane | transfer_lane | -| Non-critical and failing | skip_step | -| Restructure the plan | revise_plan (with dependencyPatches) | -| Persist run-local durable state | update_mission_state | -| Reload run-local durable state | read_mission_state | -| Log structured reflection signal | reflection_add | -| Insert milestone | insert_milestone | -| Request specialist | request_specialist | -| Delegate subtask to child agent | delegate_to_subagent | -| Delegate a parallel child-task batch | delegate_parallel | -| Stop a worker | stop_worker | -| Check budget pressure | get_budget_status | -| Check queue/finalization state | check_finalization_status | -| Need human input | request_user_input | -| Mission complete | complete_mission | -| Mission impossible | fail_mission | - -## Critical Rules -- NEVER narrate what you're about to do. Just DO it. Call the tool. -- Keep text responses SHORT. Events keep flowing; verbosity wastes your context window. -- ALWAYS act on worker completion events immediately. Read output, evaluate, decide next step. -- NEVER let a failure sit. Diagnose and act in the same turn. -- NEVER retry the same approach more than twice. If it failed twice, try something different. -- ALWAYS check budget before spawning multiple workers. -- ALWAYS validate milestone outputs before starting the next milestone. -- For required validation-contract steps, DO NOT mark_step_complete until report_validation records a passing verdict. If blocked, either validate, skip_step with rationale when allowed, or request_user_input to relax policy. -- Workers are disposable and start cold. Your persistent context is the mission's continuity — use it. -- The mission goal above is your north star. Before calling complete_mission, verify ALL aspects are addressed. -- If finalization is active, call check_finalization_status before complete_mission to ensure queue landing or PR merging is done.`; - } - -} diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts b/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts deleted file mode 100644 index ce7c0bddc..000000000 --- a/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts +++ /dev/null @@ -1,342 +0,0 @@ -// --------------------------------------------------------------------------- -// Coordinator Event Formatter — 4-tier context system for formatting runtime -// events into coordinator-digestible summaries. -// --------------------------------------------------------------------------- - -import type { - OrchestratorRuntimeEvent, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorAttempt, - OrchestratorStepStatus, - PhaseCard, -} from "../../../shared/types"; -import { resolveFirstPostPlanningPhaseKey } from "../missions/phaseEngine"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type EventTier = 1 | 2 | 3 | 4; - -export type FormattedEvent = { - tier: EventTier; - /** Tier 1: ~50 token one-liner summary. */ - summary: string; - /** Tier 2: ~200-500 token digest with context. */ - digest?: string; - /** Tier 3: Full output text, available on demand. */ - fullOutput?: string; - /** Tier 4: Shared fact keys for cross-step knowledge. */ - factKeys?: string[]; -}; - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -const TERMINAL_STATUSES = new Set([ - "succeeded", - "failed", - "skipped", - "superseded", - "canceled", -]); - -function countByStatus(steps: OrchestratorStep[]): Record { - const counts: Record = {}; - for (const step of steps) { - counts[step.status] = (counts[step.status] ?? 0) + 1; - } - return counts; -} - -function progressFraction(graph: OrchestratorRunGraph): { - done: number; - total: number; - pct: number; -} { - const total = graph.steps.length; - const done = graph.steps.filter((s) => TERMINAL_STATUSES.has(s.status)).length; - const pct = total > 0 ? Math.round((done / total) * 100) : 0; - return { done, total, pct }; -} - -function stepStatusIcon(status: OrchestratorStepStatus): string { - switch (status) { - case "succeeded": - return "\u2713"; - case "failed": - return "\u2717"; - case "running": - return "\u25B6"; - case "ready": - return "\u25CB"; - case "pending": - return "\u25CB"; - case "blocked": - return "\u26A0"; - case "skipped": - return "\u2192"; - case "superseded": - return "\u2248"; - case "canceled": - return "\u2205"; - default: - return "?"; - } -} - -function readyStepKeys(graph: OrchestratorRunGraph): string[] { - return graph.steps - .filter((s) => s.status === "ready") - .map((s) => s.stepKey); -} - -function failedStepSummaries(graph: OrchestratorRunGraph): string[] { - return graph.steps - .filter((s) => s.status === "failed") - .map((s) => { - const lastAttempt = graph.attempts - .filter((a) => a.stepId === s.id) - .sort((a, b) => (b.completedAt ?? b.createdAt).localeCompare(a.completedAt ?? a.createdAt))[0]; - const errMsg = lastAttempt?.errorMessage ?? "unknown error"; - return `${s.stepKey}: ${errMsg}`; - }); -} - -function readRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : {}; -} - -function resolveRunPhase(graph: OrchestratorRunGraph): { phaseKey: string; phaseName: string } { - const runMeta = readRecord(graph.run.metadata); - const phaseRuntime = readRecord(runMeta.phaseRuntime); - return { - phaseKey: typeof phaseRuntime.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim().toLowerCase() : "", - phaseName: typeof phaseRuntime.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim().toLowerCase() : "", - }; -} - -function resolveStepPhase(step: OrchestratorStep | undefined): { phaseKey: string; phaseName: string } { - const stepMeta = readRecord(step?.metadata); - return { - phaseKey: typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : "", - phaseName: typeof stepMeta.phaseName === "string" ? stepMeta.phaseName.trim().toLowerCase() : "", - }; -} - -function resolveGraphPhaseCards(graph: OrchestratorRunGraph): PhaseCard[] { - const runMeta = readRecord(graph.run.metadata); - const phaseRuntime = readRecord(runMeta.phaseConfiguration); - if (Array.isArray(phaseRuntime.selectedPhases)) return phaseRuntime.selectedPhases as PhaseCard[]; - if (Array.isArray(phaseRuntime.phases)) return phaseRuntime.phases as PhaseCard[]; - if (Array.isArray(runMeta.phaseOverride)) return runMeta.phaseOverride as PhaseCard[]; - if (Array.isArray(runMeta.phases)) return runMeta.phases as PhaseCard[]; - return []; -} - -function buildCoordinatorActionHints( - event: OrchestratorRuntimeEvent, - graph: OrchestratorRunGraph, - step?: OrchestratorStep, -): string[] { - const reason = String(event.reason ?? "").trim().toLowerCase(); - if (reason !== "attempt_completed" && reason !== "completed" && reason !== "skipped") { - return []; - } - - const hints: string[] = []; - const runPhase = resolveRunPhase(graph); - const stepPhase = resolveStepPhase(step); - const planningPhase = - stepPhase.phaseKey === "planning" - || stepPhase.phaseName === "planning" - || runPhase.phaseKey === "planning" - || runPhase.phaseName === "planning"; - - if (planningPhase && step?.status === "succeeded") { - 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.` - ); - } - - const allStepsTerminal = graph.steps.length > 0 && graph.steps.every((candidate) => TERMINAL_STATUSES.has(candidate.status)); - const hasRunningAttempt = graph.attempts.some((candidate) => candidate.status === "running"); - if (graph.run.status === "active" && allStepsTerminal && !hasRunningAttempt) { - hints.push( - "Coordinator action: no workers are running and all tracked steps are terminal. If the mission goal is satisfied, call complete_mission. Otherwise transition to the next phase and spawn the next worker." - ); - } - - return hints; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Format a generic runtime event into tiered context. - */ -export function formatRuntimeEvent( - event: OrchestratorRuntimeEvent, - context?: { - graph?: OrchestratorRunGraph; - step?: OrchestratorStep; - attempt?: OrchestratorAttempt; - }, -): FormattedEvent { - const { graph: g, step, attempt } = context ?? {}; - - // Build tier-1 summary - const eventLabel = event.type.replace("orchestrator-", "").replace(/-/g, "_").toUpperCase(); - const stepTag = step ? ` ${step.stepKey}` : ""; - const progressTag = - g ? (() => { - const { done, total, pct } = progressFraction(g); - return ` (${done}/${total}, ${pct}%)`; - })() : ""; - - const summary = `[${eventLabel}]${stepTag} ${event.reason}${progressTag}`; - - // Build tier-2 digest if graph is available - let digest: string | undefined; - if (g) { - const parts: string[] = [summary]; - const ready = readyStepKeys(g); - if (ready.length > 0) { - parts.push(`Ready: ${ready.join(", ")}`); - } - const failed = failedStepSummaries(g); - if (failed.length > 0) { - parts.push(`Failed: ${failed.join("; ")}`); - } - if (attempt?.resultEnvelope?.summary) { - parts.push(`Result: ${attempt.resultEnvelope.summary}`); - } - const actionHints = buildCoordinatorActionHints(event, g, step); - if (actionHints.length > 0) { - parts.push(...actionHints); - } - digest = parts.join("\n"); - } - - return { tier: g ? 2 : 1, summary, digest }; -} - -/** - * Format a step completion event with rich context. - */ -export function formatStepCompletion( - step: OrchestratorStep, - attempt: OrchestratorAttempt | null, - graph: OrchestratorRunGraph, -): FormattedEvent { - const { done, total, pct } = progressFraction(graph); - const icon = stepStatusIcon(step.status); - const summary = `[STEP_DONE] ${step.stepKey} ${icon} (${done}/${total}, ${pct}%)`; - - const parts: string[] = [summary]; - if (attempt?.resultEnvelope?.summary) { - parts.push(`Summary: ${attempt.resultEnvelope.summary}`); - } - if (attempt?.resultEnvelope?.warnings?.length) { - parts.push(`Warnings: ${attempt.resultEnvelope.warnings.join("; ")}`); - } - const ready = readyStepKeys(graph); - if (ready.length > 0) { - parts.push(`Next ready: ${ready.join(", ")}`); - } - const running = graph.steps.filter((s) => s.status === "running"); - if (running.length > 0) { - parts.push(`Running: ${running.map((s) => s.stepKey).join(", ")}`); - } - const actionHints = buildCoordinatorActionHints( - { - type: "orchestrator-step-updated", - runId: graph.run.id, - stepId: step.id, - attemptId: attempt?.id ?? undefined, - at: attempt?.completedAt ?? step.completedAt ?? step.updatedAt, - reason: step.status === "skipped" ? "skipped" : "attempt_completed", - }, - graph, - step, - ); - if (actionHints.length > 0) { - parts.push(...actionHints); - } - const digest = parts.join("\n"); - - const fullOutput = attempt?.resultEnvelope - ? JSON.stringify(attempt.resultEnvelope, null, 2) - : undefined; - - return { tier: 2, summary, digest, fullOutput }; -} - -/** - * Format a step failure event with diagnostic context. - */ -export function formatStepFailure( - step: OrchestratorStep, - attempt: OrchestratorAttempt | null, - errorMessage: string, -): FormattedEvent { - const retryInfo = - step.retryLimit > 0 - ? ` (retry ${step.retryCount}/${step.retryLimit})` - : ""; - const summary = `[STEP_FAIL] ${step.stepKey} \u2717${retryInfo}`; - - const parts: string[] = [summary, `Error: ${errorMessage}`]; - if (attempt?.errorClass && attempt.errorClass !== "none") { - parts.push(`Class: ${attempt.errorClass}`); - } - if (step.retryCount < step.retryLimit) { - parts.push(`Action: Will retry (attempt ${step.retryCount + 1}/${step.retryLimit})`); - } else if (step.retryLimit > 0) { - parts.push("Action: Retries exhausted, needs intervention"); - } - const digest = parts.join("\n"); - - return { tier: 2, summary, digest }; -} - -/** - * Build a compact progress summary string for the entire run. - */ -export function formatProgressSummary(graph: OrchestratorRunGraph): string { - const { done, total, pct } = progressFraction(graph); - const counts = countByStatus(graph.steps); - const parts: string[] = [ - `Progress: ${done}/${total} (${pct}%)`, - `Run: ${graph.run.status}`, - ]; - - const statusLine = Object.entries(counts) - .map(([status, count]) => `${status}=${count}`) - .join(" "); - parts.push(statusLine); - - const running = graph.steps.filter((s) => s.status === "running"); - if (running.length > 0) { - parts.push(`Active: ${running.map((s) => s.stepKey).join(", ")}`); - } - - const ready = readyStepKeys(graph); - if (ready.length > 0) { - parts.push(`Ready: ${ready.join(", ")}`); - } - - const failed = failedStepSummaries(graph); - if (failed.length > 0) { - parts.push(`Failed: ${failed.join("; ")}`); - } - - return parts.join(" | "); -} diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorSession.ts b/apps/desktop/src/main/services/orchestrator/coordinatorSession.ts deleted file mode 100644 index 0dc933db5..000000000 --- a/apps/desktop/src/main/services/orchestrator/coordinatorSession.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * coordinatorSession.ts - * - * Coordinator session lifecycle: start, send events, dispatch actions, - * trigger evaluations, thinking loop, and end. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import type { - OrchestratorContext, - CoordinatorSessionEntry, - OrchestratorChatMessage, -} from "./orchestratorContext"; -import { - nowIso, -} from "./orchestratorContext"; -import type { - ModelConfig, -} from "../../../shared/types"; -import { - modelConfigToServiceModel, - thinkingLevelToReasoningEffort, -} from "../../../shared/modelProfiles"; - -const DEFAULT_COORDINATOR_MODEL_CONFIG: ModelConfig = { - modelId: "anthropic/claude-sonnet-4-6", - provider: "anthropic", - thinkingLevel: "high", -}; - -// ── Coordinator Prompt Templates ───────────────────────────────── - -export const PM_SYSTEM_PREAMBLE = [ - "You are a highly experienced PM/coordinator for a software engineering mission.", - "You observe runtime events from worker agents and make real-time decisions.", - "", - "Available actions (one per line):", - " steer — deliver instruction to a running worker (or queue for non-running)", - " skip — skip a step that isn't needed", - " add_step | <instructions> — insert a new step", - " broadcast <message> — message all workers", - " escalate <reason> — flag for human intervention", - " pause <reason> — pause mission for human review", - " parallelize <stepKey> remove_dep <depStepKey> — remove a dependency to allow parallel execution", - " consolidate <keepKey> <removeKey> | <merged_instructions> — merge two steps", - " shutdown <stepKey> <reason> — graceful worker shutdown", - " acknowledged — no action needed", - "", - "Be decisive and maximize parallelism. You are a PM — direct, proactive, autonomous." -]; - -export function buildCoordinatorSystemPrompt(args: { planSummary: string }): string { - return [ - ...PM_SYSTEM_PREAMBLE, - "", - "Current mission plan:", - args.planSummary, - "", - "Respond ONLY with action commands or 'acknowledged'. Do not use any other format." - ].join("\n"); -} - -export function buildCoordinatorInitPrompt(stepCount: number): string { - return [ - `Coordinator session started. Monitoring mission with ${stepCount} steps.`, - "You will receive runtime events about step completions, failures, and blocks.", - "Review each event and decide whether to act or acknowledge.", - "Focus on maximizing parallelism and unblocking workers.", - "", - "Respond with 'acknowledged' to confirm you're online." - ].join("\n"); -} - -const COORDINATOR_EVAL_DEBOUNCE_MS = 500; - -// ── Coordinator Session Functions ──────────────────────────────── - -export async function startCoordinatorSession( - ctx: OrchestratorContext, - missionId: string, - runId: string, - plan: { steps: Array<{ stepKey: string; title: string; status: string; dependencyStepIds: string[] }> }, - coordinatorModelConfig?: ModelConfig | null, - deps?: { - emitOrchestratorMessage: (missionId: string, content: string, stepKey?: string | null, metadata?: Record<string, unknown> | null) => OrchestratorChatMessage; - resolveOrchestratorModelConfig: (missionId: string, callType: string) => ModelConfig; - resolveAiDecisionLikeTimeoutMs: (missionId: string) => number | null; - } -): Promise<string | null> { - if (!ctx.aiIntegrationService || !ctx.projectRoot) { - ctx.logger.debug("ai_orchestrator.coordinator_session_skip", { - missionId, - reason: !ctx.aiIntegrationService ? "no_ai_integration_service" : "no_project_root" - }); - return null; - } - - try { - const planSummary = plan.steps - .map((s) => `- ${s.stepKey}: "${s.title}" [${s.status}]${s.dependencyStepIds.length ? ` (deps: ${s.dependencyStepIds.length})` : ""}`) - .join("\n"); - const coordinatorSystemPrompt = buildCoordinatorSystemPrompt({ planSummary }); - const timeoutMs = deps?.resolveAiDecisionLikeTimeoutMs(missionId) ?? null; - - const resolvedConfig = coordinatorModelConfig ?? deps?.resolveOrchestratorModelConfig(missionId, "coordinator") ?? DEFAULT_COORDINATOR_MODEL_CONFIG; - const entry: CoordinatorSessionEntry = { - sessionId: null, - missionId, - runId, - modelConfig: resolvedConfig, - startedAt: nowIso(), - eventCount: 0, - lastEventAt: null, - dead: false, - startupGreetingSent: false, - systemPrompt: coordinatorSystemPrompt, - pendingInit: null - }; - ctx.coordinatorSessions.set(runId, entry); - - const initPromise = (async () => { - try { - const result = await ctx.aiIntegrationService!.executeTask({ - feature: "orchestrator" as const, - taskType: "review" as const, - prompt: buildCoordinatorInitPrompt(plan.steps.length), - cwd: ctx.projectRoot!, - model: modelConfigToServiceModel(resolvedConfig), - systemPrompt: coordinatorSystemPrompt, - reasoningEffort: thinkingLevelToReasoningEffort(resolvedConfig.thinkingLevel), - permissionMode: "read-only" as const, - oneShot: true, - ...(timeoutMs != null ? { timeoutMs } : {}) - }); - - const activeEntry = ctx.coordinatorSessions.get(runId); - if (activeEntry !== entry || entry.dead) { - return; - } - - if (typeof result.sessionId === "string" && result.sessionId.trim().length > 0) { - entry.sessionId = result.sessionId.trim(); - ctx.logger.info("ai_orchestrator.coordinator_session_started", { - missionId, - runId, - sessionId: entry.sessionId, - initResponseChars: result.text?.length ?? 0 - }); - } else { - ctx.logger.warn("ai_orchestrator.coordinator_session_no_session_id", { - missionId, - runId, - reason: "executeTask did not return a sessionId — coordinator will operate in stateless mode" - }); - } - if (!entry.startupGreetingSent && deps?.emitOrchestratorMessage) { - deps.emitOrchestratorMessage( - missionId, - "Coordinator online. Monitoring mission events and ready to intervene.", - null, - { - role: "coordinator", - runId, - sessionId: entry.sessionId, - plannerStepCount: plan.steps.length - } - ); - entry.startupGreetingSent = true; - } - } catch (error) { - entry.dead = true; - ctx.logger.warn("ai_orchestrator.coordinator_session_init_failed", { - missionId, - runId, - error: error instanceof Error ? error.message : String(error) - }); - } finally { - entry.pendingInit = null; - } - })(); - - entry.pendingInit = initPromise; - return runId; - } catch (error) { - ctx.logger.warn("ai_orchestrator.coordinator_session_start_failed", { - missionId, - runId, - error: error instanceof Error ? error.message : String(error) - }); - return null; - } -} - -export async function sendCoordinatorEvent( - ctx: OrchestratorContext, - runId: string, - eventMessage: string, - deps?: { - resolveAiDecisionLikeTimeoutMs: (missionId: string) => number | null; - parseAndDispatchCoordinatorActions: (args: { missionId: string; runId: string; responseText: string }) => { executedActions: number; proseLines: string[] }; - emitOrchestratorMessage: (missionId: string, content: string, stepKey?: string | null, metadata?: Record<string, unknown> | null) => OrchestratorChatMessage; - } -): Promise<void> { - const session = ctx.coordinatorSessions.get(runId); - if (!session || session.dead || !ctx.aiIntegrationService || !ctx.projectRoot) return; - - if (session.pendingInit) { - try { - await session.pendingInit; - } catch { - return; - } - } - - if (session.dead) return; - - try { - session.eventCount++; - session.lastEventAt = nowIso(); - const timeoutMs = deps?.resolveAiDecisionLikeTimeoutMs(session.missionId) ?? null; - - const result = await ctx.aiIntegrationService.executeTask({ - feature: "orchestrator" as const, - taskType: "review" as const, - prompt: eventMessage, - cwd: ctx.projectRoot, - model: modelConfigToServiceModel(session.modelConfig), - systemPrompt: session.systemPrompt, - reasoningEffort: thinkingLevelToReasoningEffort(session.modelConfig.thinkingLevel), - permissionMode: "read-only" as const, - oneShot: true, - ...(session.sessionId ? { sessionId: session.sessionId } : {}), - ...(timeoutMs != null ? { timeoutMs } : {}) - }); - - if (typeof result.sessionId === "string" && result.sessionId.trim().length > 0) { - session.sessionId = result.sessionId.trim(); - } - - const responseText = typeof result.text === "string" ? result.text.trim() : ""; - if (responseText.length > 0 && responseText.toLowerCase() !== "acknowledged") { - if (deps?.parseAndDispatchCoordinatorActions) { - deps.parseAndDispatchCoordinatorActions({ - missionId: session.missionId, - runId, - responseText - }); - } - } - } catch (error) { - ctx.logger.debug("ai_orchestrator.coordinator_event_failed", { - runId, - missionId: session.missionId, - error: error instanceof Error ? error.message : String(error) - }); - } -} - -export function endCoordinatorSession(ctx: OrchestratorContext, runId: string): void { - const session = ctx.coordinatorSessions.get(runId); - if (!session) return; - ctx.coordinatorSessions.delete(runId); - ctx.logger.info("ai_orchestrator.coordinator_session_ended", { - runId, - missionId: session.missionId, - sessionId: session.sessionId, - eventCount: session.eventCount, - durationMs: Date.now() - Date.parse(session.startedAt) - }); -} - -export function triggerCoordinatorEvaluation( - ctx: OrchestratorContext, - runId: string, - reason: string, - deps: { - runCoordinatorEvaluation: (runId: string, reason: string) => void; - } -): void { - if (ctx.disposed.current || !ctx.aiIntegrationService || !ctx.projectRoot) return; - - const existing = ctx.pendingCoordinatorEvals.get(runId); - if (existing) clearTimeout(existing); - - ctx.pendingCoordinatorEvals.set(runId, setTimeout(() => { - ctx.pendingCoordinatorEvals.delete(runId); - deps.runCoordinatorEvaluation(runId, reason); - }, COORDINATOR_EVAL_DEBOUNCE_MS)); -} - -export function stopCoordinatorThinkingLoop(ctx: OrchestratorContext, missionId: string): void { - const timer = ctx.coordinatorThinkingLoops.get(missionId); - if (timer) { - clearInterval(timer); - ctx.coordinatorThinkingLoops.delete(missionId); - } -} diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts deleted file mode 100644 index c0bc3996a..000000000 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts +++ /dev/null @@ -1,6575 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - classifyPlannerLaunchFailure, - createCoordinatorToolSet, - type CoordinatorWorkerDeliveryStatus, -} from "./coordinatorTools"; -import { - hasResolvedPlanningQuestionIntervention, - phaseRequiresPlanningQuestionBeforeExit, -} from "./planningQuestionPolicy"; - -function createTestDeps(args: { - graph: any; - sendWorkerMessageToSession?: (input: { - sessionId: string; - text: string; - priority?: "normal" | "urgent"; - }) => Promise<CoordinatorWorkerDeliveryStatus>; - projectRoot?: string; - workspaceRoot?: string; -}) { - const orchestratorService = { - getRunGraph: vi.fn(() => args.graph), - appendRuntimeEvent: vi.fn(), - appendTimelineEvent: vi.fn(), - emitRuntimeUpdate: vi.fn(), - addReflection: vi.fn(() => ({ - id: "reflection-1", - missionId: "mission-1", - runId: "run-1" - })), - } as any; - - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; - - const tools = createCoordinatorToolSet({ - orchestratorService, - missionService: {} as any, - projectId: "project-1", - runId: "run-1", - missionId: "mission-1", - logger, - db: {} as any, - projectRoot: args.projectRoot ?? "/tmp", - workspaceRoot: args.workspaceRoot ?? "/tmp/worktree", - onDagMutation: vi.fn(), - sendWorkerMessageToSession: args.sendWorkerMessageToSession, - }); - - return { tools, orchestratorService }; -} - -describe("coordinator project context", () => { - it("redacts the workspace root from get_project_context", async () => { - const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-coordinator-tools-")); - try { - fs.writeFileSync(path.join(workspaceRoot, "README.md"), "# Test workspace\n", "utf8"); - const { tools } = createTestDeps({ - graph: { run: { id: "run-1", metadata: {} }, steps: [], attempts: [] }, - workspaceRoot, - }); - - const result = await (tools.get_project_context as any).execute({}); - - expect(result).toMatchObject({ - ok: true, - projectRoot: "./", - projectRootRedacted: true, - rootLabel: "./", - }); - expect(JSON.stringify(result)).not.toContain(workspaceRoot); - } finally { - fs.rmSync(workspaceRoot, { recursive: true, force: true }); - } - }); -}); - -function testPhase(overrides: Record<string, any> = {}) { - 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("planning question policy", () => { - it("promotes generated false values when prompt text requires planning questions", () => { - const required = phaseRequiresPlanningQuestionBeforeExit({ - phase: testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Research, clarify requirements, and design the execution DAG.", - askQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - isBuiltIn: true, - isCustom: false, - }), - missionPrompt: [ - "Build a FleetOps launch-drill evidence console.", - "Required planning questions before implementation:", - "1. Should this use a static app or Vite?", - ].join("\n"), - }); - - expect(required).toBe(true); - }); - - it("does not require a planning question when text explicitly disables clarification", () => { - const required = phaseRequiresPlanningQuestionBeforeExit({ - phase: testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Research, clarify requirements, and design the execution DAG.", - askQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - isBuiltIn: true, - isCustom: false, - }), - missionPrompt: "Proceed without asking clarification questions before implementation.", - }); - - expect(required).toBe(false); - }); - - it("counts resolved ADE chat question cards as planning clarification", () => { - const resolved = hasResolvedPlanningQuestionIntervention([ - { - status: "resolved", - interventionType: "manual_input", - metadata: { - source: "request_user_input", - reasonCode: "planner_chat_question", - questionOwnerKind: "planner", - phaseKey: "planning", - runId: "run-1", - }, - }, - ], { runId: "run-1" }); - - expect(resolved).toBe(true); - }); -}); - -describe("classifyPlannerLaunchFailure", () => { - it("classifies run-context failures as unrecoverable run context bugs", () => { - expect(classifyPlannerLaunchFailure("Run not found: run-123")).toEqual({ - category: "run_context_bug", - interventionType: "unrecoverable_error", - reasonCode: "planner_launch_run_context_bug", - retryable: false, - recoveryOptions: ["cancel_run"], - }); - }); - - it("classifies provider outages as retryable provider failures with fallback option", () => { - expect(classifyPlannerLaunchFailure("Model provider timeout while launching planner")).toEqual({ - category: "provider_unreachable", - interventionType: "provider_unreachable", - reasonCode: "planner_launch_provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - }); - }); - - it("classifies permission denials as policy blocks", () => { - expect(classifyPlannerLaunchFailure("This command requires approval")).toEqual({ - category: "permission_denied", - interventionType: "policy_block", - reasonCode: "planner_launch_permission_denied", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - }); - }); - - it("classifies schema and validation issues without retrying", () => { - expect(classifyPlannerLaunchFailure("InputValidationError: unexpected command parameter")).toEqual({ - category: "tool_schema_error", - interventionType: "failed_step", - reasonCode: "planner_launch_tool_schema_error", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - }); - }); -}); - -function createCoordinatorHarness(args: { - graph: any; - missionMetadata?: Record<string, unknown> | null; - missionPrompt?: string | null; - missionServicePrompt?: string | null; - runMetadataJson?: Record<string, unknown> | null; - onRunFinalize?: (input: { runId: string; succeeded: boolean; summary?: string; reason?: string }) => void; - finalizeRunResult?: { finalized: boolean; blockers: string[]; finalStatus: string }; - missionLaneId?: string | null; - getMissionBudgetStatus?: () => Promise<any>; - onHardCapTriggered?: (detail: string) => void; - onBudgetWarning?: (pressure: "warning" | "critical", detail: string) => void; - projectRoot?: string; -}) { - const runMetadata = { - phaseRuntime: { - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - ...( - args.graph?.run?.metadata && typeof args.graph.run.metadata === "object" && !Array.isArray(args.graph.run.metadata) - ? args.graph.run.metadata - : {} - ), - }; - const graph = { - run: { ...(args.graph?.run ?? {}) }, - steps: [], - attempts: [], - ...(args.graph ?? {}), - }; - const graphRun = graph.run && typeof graph.run === "object" && !Array.isArray(graph.run) ? graph.run : {}; - const graphRunMetadata = - graphRun.metadata && typeof graphRun.metadata === "object" && !Array.isArray(graphRun.metadata) - ? graphRun.metadata - : {}; - graph.run = { - ...graphRun, - metadata: { - ...runMetadata, - ...graphRunMetadata, - }, - }; - const onDagMutation = vi.fn(); - - const db = { - run: vi.fn(), - get: vi.fn((query: string) => { - if (query.includes("from missions") && query.includes("metadata_json") && args.missionMetadata) { - return { metadata_json: JSON.stringify(args.missionMetadata) }; - } - if (query.includes("from missions") && query.includes("prompt") && Object.hasOwn(args, "missionPrompt")) { - return { prompt: args.missionPrompt }; - } - if (query.includes("from orchestrator_runs") && args.runMetadataJson) { - return { metadata_json: JSON.stringify(args.runMetadataJson) }; - } - return null; - }), - } as any; - - const orchestratorService = { - getRunGraph: vi.fn(() => graph), - appendRuntimeEvent: vi.fn(), - appendTimelineEvent: vi.fn(), - emitRuntimeUpdate: vi.fn(), - finalizeRun: vi.fn(() => args.finalizeRunResult ?? { - finalized: true, - blockers: [], - finalStatus: "succeeded", - }), - addReflection: vi.fn(() => ({ - id: "reflection-1", - missionId: "mission-1", - runId: "run-1" - })), - createHandoff: vi.fn(), - startReadyAutopilotAttempts: vi.fn(async () => 0), - completeAttempt: vi.fn(), - updateStepMetadata: vi.fn(({ stepId, metadata }: { stepId: string; metadata: Record<string, unknown> }) => { - const step = graph.steps.find((entry: any) => entry.id === stepId); - if (!step) return null; - step.metadata = metadata; - return step; - }), - skipStep: vi.fn(({ stepId }: { stepId: string }) => { - const step = graph.steps.find((entry: any) => entry.id === stepId); - if (step) step.status = "skipped"; - }), - addSteps: vi.fn(({ steps }: { steps: any[] }) => { - const idByKey = new Map(graph.steps.map((entry: any) => [entry.stepKey, entry.id])); - const created = steps.map((input, index) => { - const step = { - id: `step-created-${graph.steps.length + index + 1}`, - runId: "run-1", - missionStepId: null, - stepKey: input.stepKey, - stepIndex: input.stepIndex ?? graph.steps.length + index, - title: input.title ?? input.stepKey, - laneId: input.laneId ?? null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: Array.isArray(input.dependencyStepKeys) - ? input.dependencyStepKeys - .map((entry: string) => idByKey.get(entry)) - .filter((entry: string | undefined): entry is string => typeof entry === "string" && entry.length > 0) - : [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: input.metadata ?? {}, - }; - graph.steps.push(step); - return step; - }); - return created; - }), - supersedeStep: vi.fn( - ({ - stepId, - replacementStepId, - replacementStepKey, - }: { - stepId: string; - replacementStepId?: string | null; - replacementStepKey?: string | null; - }) => { - const step = graph.steps.find((entry: any) => entry.id === stepId); - if (!step) return null; - step.status = "superseded"; - step.metadata = { - ...(step.metadata ?? {}), - supersededByStepId: replacementStepId ?? null, - supersededByStepKey: replacementStepKey ?? null, - }; - return step; - } - ), - updateStepDependencies: vi.fn(({ stepId, dependencyStepKeys }: { stepId: string; dependencyStepKeys: string[] }) => { - const idByKey = new Map(graph.steps.map((entry: any) => [entry.stepKey, entry.id])); - const step = graph.steps.find((entry: any) => entry.id === stepId); - if (!step) return null; - step.dependencyStepIds = dependencyStepKeys - .map((entry) => idByKey.get(entry)) - .filter((entry): entry is string => typeof entry === "string" && entry.length > 0); - return step; - }), - } as any; - - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; - - const mission = { - id: "mission-1", - prompt: args.missionServicePrompt ?? undefined, - interventions: [] as any[], - }; - const missionService = { - get: vi.fn((missionId: string) => (missionId === mission.id ? mission : null)), - update: vi.fn((input: any) => ({ - id: input.missionId, - status: input.status, - lastError: input.lastError ?? null, - outcomeSummary: input.outcomeSummary ?? null, - })), - addIntervention: vi.fn((input: any) => { - const intervention = { - id: `intervention-${mission.interventions.length + 1}`, - interventionType: input.interventionType ?? "manual_input", - status: "open", - title: input.title ?? "", - body: input.body ?? "", - metadata: input.metadata ?? null, - }; - mission.interventions.push(intervention); - return intervention; - }), - } as any; - - const tools = createCoordinatorToolSet({ - orchestratorService, - missionService, - runId: "run-1", - missionId: "mission-1", - projectId: "project-1", - logger, - db, - projectRoot: args.projectRoot ?? "/tmp", - workspaceRoot: args.projectRoot ?? "/tmp", - onDagMutation, - onRunFinalize: args.onRunFinalize, - getMissionBudgetStatus: args.getMissionBudgetStatus, - onHardCapTriggered: args.onHardCapTriggered, - onBudgetWarning: args.onBudgetWarning, - missionLaneId: args.missionLaneId ?? undefined, - }); - - return { tools, orchestratorService, db, graph, onDagMutation, missionService, mission, logger }; -} - -describe("coordinatorTools mission lane fallback", () => { - it("uses missionLaneId when spawning workers without an explicit lane", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph, - missionLaneId: "mission-lane-42", - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "worker-no-lane", - provider: "claude", - prompt: "Do work", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.stringContaining("worker_worker-no-lane_"), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith( - expect.objectContaining({ - steps: [ - expect.objectContaining({ - laneId: "mission-lane-42", - retryLimit: 1, - }), - ], - }) - ); - }); - - it("routes request_specialist workers to missionLaneId when no explicit lane is provided", async () => { - const graph = { - run: { - metadata: { - teamRuntime: { - enabled: true, - template: { - roles: [ - { - name: "validator", - capabilities: ["validation"], - } - ] - } - } - } - }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph, - missionLaneId: "mission-lane-42", - }); - - const result = await (tools.request_specialist as any).execute({ - role: "validator", - objective: "Review implementation changes and test outcomes.", - reason: "Need an independent validation pass.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: true, - role: "validator", - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith( - expect.objectContaining({ - steps: [ - expect.objectContaining({ - laneId: "mission-lane-42", - }), - ], - }) - ); - }); -}); - -describe("coordinatorTools stop_worker safety", () => { - it("rejects stop_worker calls without a cancellation reason", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - status: "running", - laneId: "lane-1", - metadata: {}, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "running", - }, - ], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.stop_worker as any).execute({ - workerId: "worker-1", - reason: " ", - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("Cancellation reason is required."), - }); - expect(orchestratorService.completeAttempt).not.toHaveBeenCalled(); - }); - - it("rejects stop_worker when a non-failed worker emitted recent activity", async () => { - const graph = { - run: { id: "run-1", metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - status: "running", - laneId: "lane-1", - metadata: {}, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "running", - executorSessionId: "session-1", - }, - ], - }; - const { tools, orchestratorService, db } = createCoordinatorHarness({ graph }); - db.get.mockImplementation((query: string) => { - if (query.includes("from orchestrator_chat_messages")) { - return { latest_at: new Date().toISOString() }; - } - return null; - }); - - const result = await (tools.stop_worker as any).execute({ - workerId: "worker-1", - reason: "No report_result yet; cancel and retry with a shorter prompt.", - }); - - expect(result).toMatchObject({ - ok: false, - status: "active_recently", - error: expect.stringContaining("emitted activity"), - }); - expect(orchestratorService.completeAttempt).not.toHaveBeenCalled(); - }); -}); - -describe("coordinatorTools get_worker_output running workers", () => { - it("warns against canceling quiet planning workers solely for lack of terminal output", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - runId: "run-1", - missionStepId: null, - stepKey: "worker_plan-test-tab_1", - stepIndex: 0, - title: "plan-test-tab", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: "attempt-1", - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: "2026-03-02T00:00:00.000Z", - completedAt: null, - metadata: { - phaseKey: "planning", - stepType: "planning", - readOnlyExecution: true, - }, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "running", - createdAt: "2026-03-02T00:00:00.000Z", - }, - ], - }; - const { tools } = createCoordinatorHarness({ graph }); - - const result = await (tools.get_worker_output as any).execute({ - workerId: "worker_plan-test-tab_1", - }); - - expect(result).toMatchObject({ - ok: true, - status: "running", - blockers: [], - progressPct: null, - }); - expect(String(result.summary)).toContain("Planning/research workers can stay quiet"); - expect(String(result.summary)).toContain("Do not cancel solely"); - }); -}); - -describe("coordinatorTools task planning", () => { - it("spawn_worker tolerates omitted dependsOn arrays from provider tool calls", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - name: "Implement sidebar tab", - modelId: "openai/gpt-5.3-codex", - prompt: "Add the sidebar tab.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.stringContaining("worker_Implement_sidebar_tab_"), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - dependencyStepKeys: [], - }), - ], - })); - }); - - it("spawn_worker derives a worker name when the provider omits one", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - modelId: "openai/gpt-5.5", - prompt: "plan.markdown\nReturn a read-only planning result for the mission.", - }); - - expect(result).toMatchObject({ - ok: true, - name: "plan-markdown", - workerId: expect.stringContaining("worker_plan-markdown_"), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - title: "plan-markdown", - }), - ], - })); - }); - - it("spawn_worker avoids prompt boilerplate when deriving worker names", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - modelId: "openai/gpt-5.5", - prompt: [ - "You are the read-only Planning worker for ADE mission abc.", - "Mission goal:", - "Create a tiny throwaway file.", - ].join("\n"), - }); - - expect(result).toMatchObject({ - ok: true, - name: "Planning worker", - workerId: expect.stringContaining("worker_Planning_worker_"), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - title: "Planning worker", - }), - ], - })); - }); - - it("spawn_worker uses phase role names for generated worker prompts", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - modelId: "openai/gpt-5.5", - prompt: [ - "You are the Integration worker for this ADE throwaway mission.", - "Mission goal:", - "Verify the fake lane output.", - ].join("\n"), - }); - - expect(result).toMatchObject({ - ok: true, - name: "Integration worker", - }); - }); - - it("spawn_worker propagates explicit read-only execution outside planning", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - name: "Read-only development verifier", - modelId: "openai/gpt-5.5", - prompt: "Verify the smoke mission without editing files.", - readOnly: true, - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - metadata: expect.objectContaining({ - readOnlyExecution: true, - }), - }), - ], - })); - }); - - it("spawn_worker infers the completed planning worker as an upstream dependency when development omits dependsOn", async () => { - const graph = { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "openai", - }, - }, - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Research", - instructions: "", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }, - steps: [ - { - id: "step-plan", - runId: "run-1", - missionStepId: null, - stepKey: "worker_plan-sidebar_1", - stepIndex: 0, - title: "Plan sidebar work", - laneId: "lane-mission", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:05:00.000Z", - startedAt: "2026-03-02T00:00:00.000Z", - completedAt: "2026-03-02T00:05:00.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - name: "Implement sidebar tab", - modelId: "openai/gpt-5.3-codex", - prompt: "Add the sidebar tab.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.stringContaining("worker_Implement_sidebar_tab_"), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - dependencyStepKeys: ["worker_plan-sidebar_1"], - metadata: expect.objectContaining({ - requestedDependencyStepKeys: [], - inferredDependencyStepKeys: ["worker_plan-sidebar_1"], - }), - }), - ], - })); - }); - - it("create_task creates a display-only task shell", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService, graph: harnessGraph } = createCoordinatorHarness({ graph }); - - const result = await (tools.create_task as any).execute({ - key: "plan-sidebar", - title: "Plan sidebar work", - description: "Understand the sidebar touchpoints before implementation.", - dependsOn: [], - }); - - expect(result.ok).toBe(true); - expect(orchestratorService.addSteps).toHaveBeenCalledTimes(1); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - steps: [ - expect.objectContaining({ - stepKey: "plan-sidebar", - executorKind: "manual", - laneId: undefined, - metadata: expect.objectContaining({ - isTask: true, - displayOnlyTask: true, - stepType: "task", - }), - }), - ], - })); - expect(harnessGraph.steps[0]?.metadata).toEqual(expect.objectContaining({ - isTask: true, - displayOnlyTask: true, - stepType: "task", - requestedDependencyStepKeys: [], - })); - }); - - it("spawn_worker converts the matching task shell when the provider omits taskKey", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "task-1", - runId: "run-1", - missionStepId: null, - stepKey: "development-readonly-noop-verification", - stepIndex: 0, - title: "Read-only no-op development verification", - laneId: "lane-mission", - status: "ready", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: { - stepType: "task", - instructions: - "Confirm this smoke mission requires no product source edits after the accepted Planning worker returned report_result.plan.markdown. Keep the lane read-only.", - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - name: "Read-only development worker", - modelId: "openai/gpt-5.3-codex", - readOnly: true, - prompt: - "You are the read-only Development worker. Confirm this smoke mission requires no product source edits after the accepted Planning worker returned report_result.plan.markdown. Keep the lane read-only.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: "development-readonly-noop-verification", - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.updateStepMetadata).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - stepId: "task-1", - retryLimit: 1, - metadata: expect.objectContaining({ - isTask: false, - displayOnlyTask: false, - convertedFromTaskShell: true, - readOnlyExecution: true, - spawnedByCoordinator: true, - workerName: "Read-only development worker", - }), - })); - expect(orchestratorService.updateStepDependencies).toHaveBeenCalledWith({ - runId: "run-1", - stepId: "task-1", - dependencyStepKeys: [], - }); - }); - - it("spawn_worker never converts the system planner launch tracker", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "planner-tracker", - runId: "run-1", - missionStepId: null, - stepKey: "planner-launch-tracker", - stepIndex: -1000, - title: "Launch planning worker", - laneId: "lane-mission", - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: { - isTask: true, - displayOnlyTask: true, - plannerLaunchTracker: true, - systemManaged: true, - phaseKey: "planning", - phaseName: "Planning", - stepType: "planner_launch", - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - name: "Planning worker", - modelId: "openai/gpt-5.3-codex", - prompt: "Run the Planning worker and return report_result.plan.markdown.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.stringContaining("worker_Planning_worker_"), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledTimes(1); - expect(orchestratorService.updateStepMetadata).not.toHaveBeenCalledWith(expect.objectContaining({ - stepId: "planner-tracker", - })); - }); - - it("create_task infers the completed planning worker as an upstream dependency when development omits dependsOn", async () => { - const graph = { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Research", - instructions: "", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }, - steps: [ - { - id: "step-plan", - runId: "run-1", - missionStepId: null, - stepKey: "worker_plan-sidebar_1", - stepIndex: 0, - title: "Plan sidebar work", - laneId: "lane-mission", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:05:00.000Z", - startedAt: "2026-03-02T00:00:00.000Z", - completedAt: "2026-03-02T00:05:00.000Z", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.create_task as any).execute({ - key: "implement-sidebar", - title: "Implement sidebar tab", - description: "Add the new sidebar tab.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: true, - taskKey: "implement-sidebar", - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - stepKey: "implement-sidebar", - laneId: "lane-mission", - dependencyStepKeys: ["worker_plan-sidebar_1"], - metadata: expect.objectContaining({ - requestedDependencyStepKeys: [], - inferredDependencyStepKeys: ["worker_plan-sidebar_1"], - }), - }), - ], - })); - }); - - it("create_task can place future phase task shells outside the current phase", async () => { - const phaseBase = { - description: "", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - const phases = [ - { ...phaseBase, id: "phase-planning", phaseKey: "planning", name: "Planning", position: 0 }, - { ...phaseBase, id: "phase-development", phaseKey: "development", name: "Development", position: 1 }, - { ...phaseBase, id: "phase-testing", phaseKey: "testing", name: "Testing", position: 2 }, - { ...phaseBase, id: "phase-validation", phaseKey: "validation", name: "Validation", position: 3 }, - ]; - const graph = { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - phaseConfiguration: { - selectedPhases: phases, - }, - }, - }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService, graph: harnessGraph } = createCoordinatorHarness({ graph }); - - const explicit = await (tools.create_task as any).execute({ - key: "qa-browser-pass", - title: "QA browser pass", - description: "Run the configured testing checks.", - phaseKey: "testing", - dependsOn: [], - }); - const inferred = await (tools.create_task as any).execute({ - key: "validation-closeout-note", - title: "Validation closeout note", - description: "Confirm final mission status.", - dependsOn: [], - }); - - expect(explicit).toMatchObject({ ok: true, phaseKey: "testing", phaseName: "Testing" }); - expect(inferred).toMatchObject({ ok: true, phaseKey: "validation", phaseName: "Validation" }); - expect(orchestratorService.addSteps).toHaveBeenCalledTimes(2); - expect(harnessGraph.steps[0]?.metadata).toEqual(expect.objectContaining({ - phaseKey: "testing", - phaseName: "Testing", - stepType: "task", - })); - expect(harnessGraph.steps[1]?.metadata).toEqual(expect.objectContaining({ - phaseKey: "validation", - phaseName: "Validation", - stepType: "task", - })); - }); - - it("create_task rejects missing text fields without throwing", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.create_task as any).execute({ - key: "plan-sidebar", - dependsOn: undefined, - }); - - expect(result).toMatchObject({ - ok: false, - error: "Task title is required.", - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("create_task is blocked while the mission is still in planning", async () => { - const graph = { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - }, - }, - steps: [], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph, - missionMetadata: { - phaseConfiguration: { - phases: [ - { - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Plan first", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - { - phaseKey: "development", - name: "Development", - position: 1, - instructions: "Build second", - validationGate: { tier: "self", required: false }, - budget: {}, - }, - ], - }, - }, - }); - - const result = await (tools.create_task as any).execute({ - key: "plan-sidebar", - title: "Plan sidebar work", - description: "Understand the sidebar touchpoints before implementation.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("Planning should be represented by the planning worker itself."), - blockedByPhaseOrdering: true, - phaseTransitionRequired: { - phaseKey: "development", - reason: "planning_complete", - }, - requiredTool: { - name: "set_current_phase", - args: { - phaseKey: "development", - reason: "planning_complete", - }, - }, - }); - 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: {} }, - steps: [ - { - id: "worker-step-1", - runId: "run-1", - missionStepId: null, - stepKey: "worker_implement-test-tab_1", - stepIndex: 0, - title: "implement-test-tab", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: "2026-03-02T00:00:00.000Z", - completedAt: null, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService, graph: harnessGraph } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_status as any).execute({ - workerId: "worker_implement-test-tab_1", - progressPct: 42, - nextAction: "checking the existing routing pattern", - }); - - expect(result).toMatchObject({ - ok: true, - report: { - blockers: [], - nextAction: "checking the existing routing pattern", - progressPct: 42, - }, - }); - expect(orchestratorService.updateStepMetadata).toHaveBeenCalledTimes(1); - expect(harnessGraph.steps[0]?.metadata).toEqual(expect.objectContaining({ - lastStatusReport: expect.objectContaining({ - blockers: [], - nextAction: "checking the existing routing pattern", - }), - })); - }); - - it("report_result tolerates omitted artifact and file arrays from runtime tool calls", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "worker-step-1", - runId: "run-1", - missionStepId: null, - stepKey: "worker_implement-test-tab_1", - stepIndex: 0, - title: "implement-test-tab", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: "2026-03-02T00:00:00.000Z", - completedAt: null, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService, graph: harnessGraph } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_result as any).execute({ - workerId: "worker_implement-test-tab_1", - outcome: "succeeded", - summary: "done with the read-only research pass", - }); - - expect(result).toMatchObject({ - ok: true, - report: { - artifacts: [], - filesChanged: [], - summary: "done with the read-only research pass", - }, - }); - expect(orchestratorService.createHandoff).toHaveBeenCalledTimes(1); - expect(harnessGraph.steps[0]?.metadata).toEqual(expect.objectContaining({ - lastResultReport: expect.objectContaining({ - artifacts: [], - filesChanged: [], - }), - })); - }); - - it("spawn_worker resolves manual task dependencies onto executable workers", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "task-1", - runId: "run-1", - missionStepId: null, - stepKey: "plan-sidebar", - stepIndex: 0, - title: "Plan sidebar work", - laneId: null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: { - isTask: true, - stepType: "task", - assignedTo: "research-sidebar", - }, - }, - { - id: "worker-1", - runId: "run-1", - missionStepId: null, - stepKey: "research-sidebar", - stepIndex: 1, - title: "Research sidebar", - laneId: "lane-mission", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: null, - completedAt: "2026-03-02T00:05:00.000Z", - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, missionLaneId: "lane-mission" }); - - const result = await (tools.spawn_worker as any).execute({ - name: "Implement sidebar tab", - modelId: "openai/gpt-5.3-codex", - prompt: "Add the sidebar tab.", - dependsOn: ["plan-sidebar"], - }); - - expect(result.ok).toBe(true); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - steps: [ - expect.objectContaining({ - dependencyStepKeys: ["research-sidebar"], - metadata: expect.objectContaining({ - requestedDependencyStepKeys: ["plan-sidebar"], - planningTaskDependencies: ["plan-sidebar"], - }), - }), - ], - })); - }); - - it("read_mission_status excludes legacy task shells from execution counts", async () => { - const graph = { - run: { metadata: {}, status: "active" }, - steps: [ - { - id: "task-1", - runId: "run-1", - missionStepId: null, - stepKey: "plan-sidebar", - stepIndex: 0, - title: "Plan sidebar work", - laneId: null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: { - isTask: true, - stepType: "task", - }, - }, - { - id: "worker-1", - runId: "run-1", - missionStepId: null, - stepKey: "impl-sidebar", - stepIndex: 1, - title: "Implement sidebar", - laneId: "lane-mission", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - startedAt: "2026-03-02T00:01:00.000Z", - completedAt: "2026-03-02T00:04:00.000Z", - metadata: { - spawnedByCoordinator: true, - lastResultReport: { summary: "Done" }, - }, - }, - ], - attempts: [], - }; - const { tools } = createCoordinatorHarness({ graph }); - - const result = await (tools.read_mission_status as any).execute({}); - - expect(result.ok).toBe(true); - expect(result.counts).toEqual(expect.objectContaining({ - total: 1, - active: 0, - completed: 1, - })); - expect(result.completedSteps).toEqual([ - expect.objectContaining({ - stepKey: "impl-sidebar", - status: "succeeded", - }), - ]); - }); - - it("classifies test-runner workers as testing even when the active phase is still development", async () => { - const phaseCards = [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }, - { - id: "phase-testing", - phaseKey: "testing", - name: "Testing", - description: "Run tests", - instructions: "Run tests, collect failures, and feed remediation details back into execution.", - model: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }, - ]; - const graph = { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - }, - }, - }, - steps: [ - { - id: "step-implementation", - stepKey: "implement_feature", - stepIndex: 0, - title: "Implement feature", - laneId: "lane-mission", - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph, - missionLaneId: "lane-mission", - missionMetadata: { - phaseConfiguration: { - selectedPhases: phaseCards, - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "run-targeted-test", - prompt: "Run the targeted Vitest file and report failures. Do not edit files.", - dependsOn: ["implement_feature"], - }); - - expect(result).toMatchObject({ - ok: false, - blockedByPhaseOrdering: true, - phaseTransitionRequired: { - phaseKey: "testing", - reason: "target_phase_required", - }, - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); -}); - -describe("coordinatorTools planning manual-input blocking", () => { - it("blocks ask_user when the current phase disables questions", async () => { - const { tools } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.4", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Ship the code.", - instructions: "Implement the work.", - model: { modelId: "openai/gpt-5.4", provider: "openai", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.ask_user as any).execute({ - questions: [{ question: "Should I refactor the settings dialog too?" }], - phase: "development", - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("Ask Questions is disabled for the current phase"), - }); - }); - - it("still allows coordinator request_user_input for orchestration questions when phase questions are disabled", async () => { - const { tools } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.4", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Ship the code.", - instructions: "Implement the work.", - model: { modelId: "openai/gpt-5.4", provider: "openai", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.request_user_input as any).execute({ - question: "Should ADE continue in this workspace or switch to the integration lane?", - canProceedWithoutAnswer: false, - urgency: "high", - }); - - expect(result).toMatchObject({ - ok: true, - awaitingUserResponse: true, - blocking: true, - }); - }); - - it("allows spawning the planning worker before any planning question is asked", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, maxQuestions: 3 }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: "Research the codebase and produce the plan, including a testing and validation checklist.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.any(String), - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - metadata: expect.objectContaining({ - phaseKey: "planning", - instructions: expect.stringContaining("Planning worker report contract"), - phaseAskQuestions: expect.objectContaining({ - enabled: true, - maxQuestions: 3, - }), - }), - }), - ], - })); - const addedStep = (orchestratorService.addSteps as any).mock.calls.at(-1)?.[0]?.steps?.[0]; - expect(addedStep?.metadata?.instructions).toContain("Research the codebase and produce the plan"); - expect(addedStep?.metadata?.instructions).toContain("plan.markdown"); - expect(addedStep?.metadata?.instructions).toContain("### report_result"); - }); - - it("stamps planning workers when phase policy requires a blocking question before exit", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - ...testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Ask one clarification before planning.", - askQuestions: { enabled: true, maxQuestions: null }, - orderingConstraints: { mustBeFirst: true }, - position: 0, - }), - isBuiltIn: true, - isCustom: false, - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: "Research the codebase and produce the plan.", - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - metadata: expect.objectContaining({ - phaseAskQuestions: expect.objectContaining({ - enabled: true, - maxQuestions: null, - requiredBeforeExit: true, - }), - }), - }), - ], - })); - }); - - it("uses the persisted mission prompt when stamping required planning questions", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionPrompt: [ - "Build a FleetOps launch-drill evidence console.", - "Required planning questions before implementation:", - "1. Should this mission use static HTML or Vite?", - ].join("\n"), - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - ...testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Investigate the codebase and produce a concrete execution plan.", - askQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - orderingConstraints: { mustBeFirst: true }, - position: 0, - }), - isBuiltIn: true, - isCustom: false, - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: "Research the codebase and produce the plan.", - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - metadata: expect.objectContaining({ - phaseAskQuestions: expect.objectContaining({ - enabled: true, - maxQuestions: null, - requiredBeforeExit: true, - }), - }), - }), - ], - })); - }); - - it("does not let a short mission-service prompt hide required planning text from persistence", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionServicePrompt: "Build a FleetOps launch-drill evidence console.", - missionPrompt: [ - "Build a FleetOps launch-drill evidence console.", - "Planning is blocked until the planner asks clarification questions through ADE.", - "Required planning questions before implementation:", - "1. Should this mission use static HTML or Vite?", - ].join("\n"), - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - ...testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Investigate the codebase and produce a concrete execution plan.", - askQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - orderingConstraints: { mustBeFirst: true }, - position: 0, - }), - isBuiltIn: true, - isCustom: false, - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: "Research the codebase and produce the plan.", - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - metadata: expect.objectContaining({ - phaseAskQuestions: expect.objectContaining({ - enabled: true, - maxQuestions: null, - requiredBeforeExit: true, - }), - }), - }), - ], - })); - }); - - it("uses the worker prompt as a planning-question policy fallback", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionServicePrompt: "Build a FleetOps launch-drill evidence console.", - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - ...testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Investigate the codebase and produce a concrete execution plan.", - askQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - orderingConstraints: { mustBeFirst: true }, - position: 0, - }), - isBuiltIn: true, - isCustom: false, - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: [ - "Research the codebase and produce the plan.", - "Planning is blocked until the planner asks clarification questions through ADE.", - "Required planning questions before implementation:", - "1. Should this mission use static HTML or Vite?", - ].join("\n"), - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - steps: [ - expect.objectContaining({ - metadata: expect.objectContaining({ - phaseAskQuestions: expect.objectContaining({ - enabled: true, - maxQuestions: null, - requiredBeforeExit: true, - }), - }), - }), - ], - })); - }); - - it("allows implementation workers whose prompts mention a validation checklist", async () => { - const phases = [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-implementation-math", - phaseKey: "implementation_math", - name: "Implementation math", - description: "Implement math utilities.", - instructions: "Add math utilities.", - model: { modelId: "openai/gpt-5.3-codex-spark", provider: "codex", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true }, - isBuiltIn: false, - isCustom: true, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseConfiguration: { phases }, - phaseRuntime: { - currentPhaseKey: "implementation_math", - currentPhaseName: "Implementation math", - currentPhaseModel: phases[1].model, - }, - }, - }, - steps: [ - { - id: "step-planning", - runId: "run-1", - stepKey: "planning-worker", - status: "succeeded", - dependencyStepIds: [], - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "planning" }, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - completedAt: "2026-03-02T00:00:00.000Z", - }, - ], - attempts: [], - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "implementation-math-worker", - prompt: "Implement median, range, and clamp. Keep a validation checklist for npm test after implementation.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.any(String), - }); - expect(orchestratorService.addSteps).toHaveBeenCalled(); - }); - - it("blocks coordinator-side file reads during planning", async () => { - const { tools } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.read_file as any).execute({ - filePath: "package.json", - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("disabled during Planning"), - }); - }); - - it("blocks planning actions when a blocking request_user_input intervention is open", async () => { - const { tools } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: true }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const request = await (tools.request_user_input as any).execute({ - question: "Should the new tab be navigable or a placeholder?", - canProceedWithoutAnswer: false, - urgency: "normal", - }); - expect(request).toMatchObject({ - ok: true, - awaitingUserResponse: true, - blocking: true, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: "Sketch the planning DAG.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("Planning input is still pending"), - }); - }); - - it("allows planning actions to continue when manual input is explicitly optional", async () => { - const { tools, mission } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - // Inject a non-blocking open intervention (canProceedWithoutAnswer=true) - mission.interventions.push({ - id: "intervention-optional", - interventionType: "manual_input", - status: "open", - title: "Any preference on naming?", - body: "Any preference on naming?", - metadata: { - source: "request_user_input", - runId: "run-1", - question: "Any preference on naming?", - canProceedWithoutAnswer: true, - blocking: false, - phase: "planning", - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: "Sketch the planning DAG.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: true, - workerId: expect.stringContaining("worker_planning-worker_"), - }); - }); - - it("requires a phase transition before spawning more workers after planning completes", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - 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: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the planned changes.", - model: { modelId: "openai/gpt-5.3-codex", provider: "codex", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "implementation-worker", - prompt: "Implement the feature now.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: 'Planning phase already produced a completed worker result. Call set_current_phase with phaseKey "development" before spawning more workers.', - blockedByPhaseOrdering: true, - phaseTransitionRequired: { - phaseKey: "development", - reason: "planning_complete", - }, - requiredTool: { - name: "set_current_phase", - args: { - phaseKey: "development", - reason: "planning_complete", - }, - }, - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("blocks phase transitions when the planning phase only failed", async () => { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - const developmentPhase = { - ...planningPhase, - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the planned changes.", - model: { modelId: "openai/gpt-5.3-codex", provider: "codex", thinkingLevel: "medium" }, - orderingConstraints: { mustBeFirst: false, mustBeLast: false, mustFollow: ["planning"], mustPrecede: [], canLoop: false, loopTarget: null }, - position: 1, - }; - - const { tools, db } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - }, - }, - steps: [ - { - id: "step-plan-1", - stepKey: "worker-plan", - stepIndex: 0, - title: "Planning worker", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "planning", - phaseName: "Planning", - stepType: "planning", - readOnlyExecution: true, - }, - }, - ], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [planningPhase, developmentPhase], - }, - }, - }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "development", - }); - - expect(result).toMatchObject({ - ok: false, - error: "Planning phase has not completed yet. Wait for a planning worker to succeed before transitioning." - }); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("blocks leaving planning until a required planning question has been resolved", async () => { - const planningPhase = testPhase({ - phaseKey: "planning", - name: "Planning", - instructions: "Ask one clarification before planning.", - askQuestions: { enabled: true, requiredBeforeExit: true }, - orderingConstraints: { mustBeFirst: true }, - isBuiltIn: true, - isCustom: false, - position: 0, - }); - const developmentPhase = testPhase({ - phaseKey: "development", - name: "Development", - instructions: "Implement the planned changes.", - orderingConstraints: { mustFollow: ["planning"] }, - position: 1, - }); - const { tools, db, mission } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.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: [planningPhase, developmentPhase], - }, - }, - }); - - const blocked = await (tools.set_current_phase as any).execute({ - phaseKey: "development", - }); - - expect(blocked).toMatchObject({ - ok: false, - error: expect.stringContaining("Planning requires a resolved blocking question"), - }); - expect(db.run).not.toHaveBeenCalled(); - - mission.interventions.push({ - id: "intervention-answered", - status: "resolved", - interventionType: "manual_input", - metadata: { - source: "ask_user", - runId: "run-1", - phase: "planning", - }, - }); - - const allowed = await (tools.set_current_phase as any).execute({ - phaseKey: "development", - }); - - expect(allowed).toMatchObject({ - ok: true, - changed: true, - currentPhaseKey: "development", - }); - expect(db.run).toHaveBeenCalled(); - }); - - it("blocks required phase transitions when the predecessor only skipped", async () => { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - const developmentPhase = { - ...planningPhase, - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the planned changes.", - model: { modelId: "openai/gpt-5.3-codex", provider: "codex", thinkingLevel: "medium" }, - validationGate: { tier: "dedicated", required: true }, - position: 1, - }; - const validationPhase = { - ...planningPhase, - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Validate the feature.", - instructions: "Validate the planned changes.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - orderingConstraints: { mustBeFirst: false, mustBeLast: true, mustFollow: ["development"], mustPrecede: [], canLoop: false, loopTarget: null }, - validationGate: { tier: "dedicated", required: true }, - position: 2, - }; - - const { tools, db } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: developmentPhase.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, - }, - }, - { - id: "step-dev-1", - stepKey: "implement", - stepIndex: 1, - title: "Implementation worker", - laneId: null, - status: "skipped", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - }, - }, - ], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [planningPhase, developmentPhase, validationPhase], - }, - }, - }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "validation", - }); - - expect(result).toMatchObject({ - ok: false, - error: "Cannot enter phase 'Validation' before 'Development' has succeeded." - }); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("blocks required phase transitions when predecessor validation is still pending", async () => { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - const developmentPhase = { - ...planningPhase, - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the planned changes.", - model: { modelId: "openai/gpt-5.3-codex", provider: "codex", thinkingLevel: "medium" }, - orderingConstraints: { mustBeFirst: false, mustBeLast: false, mustFollow: ["planning"], mustPrecede: [], canLoop: false, loopTarget: null }, - validationGate: { tier: "self", required: true }, - position: 1, - }; - const validationPhase = { - ...planningPhase, - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Validate the feature.", - instructions: "Validate the planned changes.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - orderingConstraints: { mustBeFirst: false, mustBeLast: true, mustFollow: ["development"], mustPrecede: [], canLoop: false, loopTarget: null }, - validationGate: { tier: "dedicated", required: true }, - position: 2, - }; - - const { tools, db } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: developmentPhase.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, - }, - }, - { - id: "step-dev-1", - stepKey: "implement", - stepIndex: 1, - title: "Implementation worker", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "development", - phaseName: "Development", - stepType: "implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Implementation must be validated.", - evidence: [], - }, - }, - }, - ], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [planningPhase, developmentPhase, validationPhase], - }, - }, - }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "validation", - }); - - expect(result).toMatchObject({ - ok: false, - error: "Cannot enter phase 'Validation' until 'Development' succeeds (mustFollow)." - }); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("allows closeout after a validation phase with auto-spawned validator proof", async () => { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - const validationPhase = { - ...planningPhase, - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Validate the feature.", - instructions: "Validate the planned changes.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - orderingConstraints: { mustBeFirst: false, mustBeLast: false, mustFollow: ["planning"], mustPrecede: [], canLoop: false, loopTarget: null }, - validationGate: { tier: "dedicated", required: true }, - position: 1, - }; - const closeoutPhase = { - ...planningPhase, - id: "phase-closeout", - phaseKey: "closeout", - name: "Closeout", - description: "Summarize and finish.", - instructions: "Complete the mission.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - orderingConstraints: { mustBeFirst: false, mustBeLast: true, mustFollow: ["validation"], mustPrecede: [], canLoop: false, loopTarget: null }, - validationGate: { tier: "none", required: false }, - position: 2, - }; - - const { tools, db } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "validation", - currentPhaseName: "Validation", - currentPhaseModel: validationPhase.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, - }, - }, - { - id: "step-auto-validator", - stepKey: "validate_worker-test", - stepIndex: 1, - title: "Validate: testing worker", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "validation", - phaseName: "Validation", - stepType: "validation", - autoSpawnedValidation: true, - targetStepKey: "worker-test", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validate testing output.", - evidence: [], - }, - }, - }, - { - id: "step-validation-1", - stepKey: "worker-validation", - stepIndex: 2, - title: "Validation worker", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "validation", - phaseName: "Validation", - stepType: "validation", - validationState: "pass", - validationPassedAt: "2026-03-02T00:10:00.000Z", - lastValidationReport: { - verdict: "pass", - summary: "Validation evidence passed.", - }, - }, - }, - ], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [planningPhase, validationPhase, closeoutPhase], - }, - }, - }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "closeout", - reason: "validation_complete", - }); - - expect(result).toMatchObject({ - ok: true, - changed: true, - previousPhaseKey: null, - currentPhaseKey: "closeout", - currentPhaseName: "Closeout", - }); - expect(db.run).toHaveBeenCalled(); - - const status = await (tools.read_mission_status as any).execute({}); - expect(status.openObligations).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - stepKey: "validate_worker-test", - code: "missing_validation_report", - }), - ]) - ); - }); - - it("rejects explicit model overrides that do not match the active phase model", async () => { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - const developmentPhase = { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the planned changes.", - model: { modelId: "openai/gpt-5.3-codex", provider: "codex", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: false, mustBeLast: false, mustFollow: ["planning"], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [planningPhase, developmentPhase], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "implementation-worker", - modelId: "openai/gpt-5.3-codex", - prompt: "Implement the feature now.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: - 'Current phase "Planning" is configured for model "anthropic/claude-sonnet-4-6". Omit modelId to use the phase model, or call set_current_phase before switching models.', - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("rejects implementation-style prompts while the current phase is planning", async () => { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }; - - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [planningPhase], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "planning-worker", - prompt: [ - "## Worktree Path", - "You are working in: /tmp/worktree", - "", - "All file edits MUST be made in the worktree path above.", - "", - "## Files to Edit", - "### 1. Create NEW file: `src/TestPage.tsx`", - "Run `git add -A` and `git commit -m \"feat: add test page\"`." - ].join("\n"), - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('Current phase is "planning". Planning workers must stay read-only'), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("blocks validation-like workers before the validation phase opens", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "Implement the work.", - model: { modelId: "openai/gpt-5.3-codex", provider: "codex", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Validate", - instructions: "Validate the completed work.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "validate-test-tab", - prompt: "You are a validation worker. Use report_validation with verdict \"pass\" or \"fail\".", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("Validation workers can only be spawned during a validation-capable phase."), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); -}); - -describe("coordinatorTools budget hard-cap guards", () => { - it("spawn_worker allows API/local phase models and creates opencode worker steps", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseModel: { - modelId: "opencode/openai/gpt-5.4", - provider: "openai" - } - } - } - }, - 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: [], - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "blocked-api-worker", - prompt: "Attempt API worker spawn", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: true, - modelId: "opencode/openai/gpt-5.4", - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith( - expect.objectContaining({ - steps: [ - expect.objectContaining({ - executorKind: "opencode", - }), - ], - }) - ); - }); - - it("spawn_worker blocks and reports hard caps before mutating the DAG", async () => { - const getMissionBudgetStatus = vi.fn(async () => ({ - perProvider: [ - { - provider: "claude", - fiveHour: { usedPct: 96 }, - weekly: { usedPct: 32 }, - }, - ], - hardCaps: { - fiveHourTriggered: true, - weeklyTriggered: false, - apiKeyTriggered: false, - fiveHourHardStopPercent: 95, - weeklyHardStopPercent: 90, - apiKeySpentUsd: 0, - apiKeyMaxSpendUsd: null, - }, - })); - const onHardCapTriggered = vi.fn(); - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - getMissionBudgetStatus, - onHardCapTriggered, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "blocked-worker", - provider: "claude", - prompt: "Do work", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - hardCapTriggered: true, - }); - expect(String(result.error)).toContain("Cannot spawn worker:"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(onHardCapTriggered).toHaveBeenCalledTimes(1); - expect(String(onHardCapTriggered.mock.calls[0]?.[0] ?? "")).toContain("5hr usage"); - }); - - it("request_specialist blocks on hard caps before worker creation", async () => { - const getMissionBudgetStatus = vi.fn(async () => ({ - perProvider: [ - { - provider: "codex", - fiveHour: { usedPct: 12 }, - weekly: { usedPct: 91 }, - }, - ], - hardCaps: { - fiveHourTriggered: false, - weeklyTriggered: true, - apiKeyTriggered: false, - fiveHourHardStopPercent: 95, - weeklyHardStopPercent: 90, - apiKeySpentUsd: 0, - apiKeyMaxSpendUsd: null, - }, - })); - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - teamRuntime: { - enabled: true, - template: { - roles: [ - { - name: "validator", - capabilities: ["validation"], - } - ] - } - } - } - }, - steps: [], - attempts: [], - }, - getMissionBudgetStatus, - }); - - const result = await (tools.request_specialist as any).execute({ - role: "validator", - objective: "Validate test integrity.", - reason: "Independent verifier required.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - hardCapTriggered: true, - }); - expect(String(result.error)).toContain("Cannot spawn specialist:"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("revise_plan blocks replacement worker spawning when hard caps are active", async () => { - const getMissionBudgetStatus = vi.fn(async () => ({ - perProvider: [], - hardCaps: { - fiveHourTriggered: false, - weeklyTriggered: false, - apiKeyTriggered: true, - fiveHourHardStopPercent: 95, - weeklyHardStopPercent: 90, - apiKeySpentUsd: 51.25, - apiKeyMaxSpendUsd: 50, - }, - })); - const { tools, orchestratorService, onDagMutation, graph } = createCoordinatorHarness({ - graph: { - run: { metadata: {} }, - steps: [ - { - id: "step-legacy", - stepKey: "legacy", - stepIndex: 0, - title: "Legacy", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {} - } - ], - attempts: [], - }, - getMissionBudgetStatus, - }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - replaceStepKeys: ["legacy"], - replacementMap: [], - dependencyPatches: [], - reason: "Need better plan.", - newSteps: [ - { - key: "replacement", - title: "Replacement", - description: "Implement replacement task.", - dependsOn: [], - provider: "claude", - replaces: ["legacy"] - } - ], - }); - - expect(result).toMatchObject({ - ok: false, - hardCapTriggered: true, - }); - expect(String(result.error)).toContain("Cannot revise plan"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.supersedeStep).not.toHaveBeenCalled(); - expect(orchestratorService.updateStepDependencies).not.toHaveBeenCalled(); - expect(orchestratorService.appendRuntimeEvent).not.toHaveBeenCalled(); - expect(orchestratorService.appendTimelineEvent).not.toHaveBeenCalled(); - expect(onDagMutation).not.toHaveBeenCalled(); - expect(graph.steps.find((entry: any) => entry.id === "step-legacy")?.status).toBe("pending"); - }); -}); - -describe("coordinatorTools delegate_parallel", () => { - it("creates a batch of sub-agents under one parent with dependency and lane inheritance", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-parent", - stepKey: "parent-worker", - stepIndex: 0, - title: "Parent Worker", - laneId: "lane-parent", - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: { modelId: "anthropic/claude-sonnet-4-6" }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-worker", - tasks: [ - { name: "child-a", prompt: "Implement API changes." }, - { name: "child-b", prompt: "Write tests for API changes." }, - ], - }); - - expect(result).toMatchObject({ - ok: true, - parentWorkerId: "parent-worker", - total: 2, - children: [ - expect.objectContaining({ name: "child-a" }), - expect.objectContaining({ name: "child-b" }), - ], - }); - expect(orchestratorService.addSteps).toHaveBeenCalledTimes(2); - for (const call of orchestratorService.addSteps.mock.calls) { - expect(call[0]).toMatchObject({ - runId: "run-1", - steps: [ - expect.objectContaining({ - laneId: "lane-parent", - dependencyStepKeys: ["parent-worker"], - }), - ], - }); - } - expect(orchestratorService.updateStepMetadata).toHaveBeenCalledTimes(2); - for (const call of orchestratorService.updateStepMetadata.mock.calls) { - expect(call[0]).toMatchObject({ - runId: "run-1", - metadata: expect.objectContaining({ - parentWorkerId: "parent-worker", - parentStepId: "step-parent", - isSubAgent: true, - }), - }); - } - expect(orchestratorService.startReadyAutopilotAttempts).toHaveBeenCalledTimes(1); - expect(orchestratorService.startReadyAutopilotAttempts).toHaveBeenCalledWith( - expect.objectContaining({ - runId: "run-1", - reason: "coordinator_delegate_parallel", - }) - ); - }); - - it("rejects batch delegation when allowSubAgents=false", async () => { - const graph = { - run: { - metadata: { - teamRuntime: { - enabled: true, - allowSubAgents: false, - }, - }, - }, - steps: [ - { - id: "step-parent", - stepKey: "parent-worker", - stepIndex: 0, - title: "Parent Worker", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-worker", - tasks: [{ name: "child-a", prompt: "Do work." }], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("allowSubAgents=false"), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.startReadyAutopilotAttempts).not.toHaveBeenCalled(); - }); - - it("rejects batch delegation when allowParallelAgents=false", async () => { - const graph = { - run: { - metadata: { - teamRuntime: { - enabled: true, - allowParallelAgents: false, - }, - }, - }, - steps: [ - { - id: "step-parent", - stepKey: "parent-worker", - stepIndex: 0, - title: "Parent Worker", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-worker", - tasks: [{ name: "child-a", prompt: "Do work." }], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("allowParallelAgents=false"), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.startReadyAutopilotAttempts).not.toHaveBeenCalled(); - }); - - it("applies model cascade per task (explicit -> phase model)", async () => { - const graph = { - run: { - metadata: { - teamRuntime: { - enabled: true, - template: { - roles: [ - { - name: "validator", - capabilities: ["validation"], - } - ] - } - }, - phaseRuntime: { - currentPhaseModel: { - modelId: "opencode/openai/gpt-5.4", - provider: "openai", - }, - }, - }, - }, - steps: [ - { - id: "step-parent-model", - stepKey: "parent-model", - stepIndex: 0, - title: "Parent With Model", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: { modelId: "anthropic/claude-sonnet-4-6" }, - }, - { - id: "step-parent-phase", - stepKey: "parent-phase", - stepIndex: 1, - title: "Parent Without Model", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools } = createCoordinatorHarness({ graph }); - - const firstBatch = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-model", - tasks: [ - { name: "explicit-model", prompt: "Task A", modelId: "openai/gpt-5.3-codex" }, - { name: "role-default", prompt: "Task B", role: "validator" }, - { name: "parent-fallback", prompt: "Task C" }, - ], - }); - - expect(firstBatch).toMatchObject({ - ok: true, - children: [ - expect.objectContaining({ name: "explicit-model", modelId: "openai/gpt-5.3-codex" }), - expect.objectContaining({ name: "role-default", modelId: "opencode/openai/gpt-5.4" }), - expect.objectContaining({ name: "parent-fallback", modelId: "opencode/openai/gpt-5.4" }), - ], - }); - - const secondBatch = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-phase", - tasks: [{ name: "phase-fallback", prompt: "Task D" }], - }); - - expect(secondBatch).toMatchObject({ - ok: true, - children: [ - expect.objectContaining({ name: "phase-fallback", modelId: "opencode/openai/gpt-5.4" }), - ], - }); - }); - - it("blocks delegate_parallel on hard caps before mutating the DAG", async () => { - const getMissionBudgetStatus = vi.fn(async () => ({ - perProvider: [ - { - provider: "claude", - fiveHour: { usedPct: 98 }, - weekly: { usedPct: 35 }, - }, - ], - hardCaps: { - fiveHourTriggered: true, - weeklyTriggered: false, - apiKeyTriggered: false, - fiveHourHardStopPercent: 95, - weeklyHardStopPercent: 90, - apiKeySpentUsd: 0, - apiKeyMaxSpendUsd: null, - }, - })); - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { metadata: {} }, - steps: [ - { - id: "step-parent", - stepKey: "parent-worker", - stepIndex: 0, - title: "Parent Worker", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }, - getMissionBudgetStatus, - }); - - const result = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-worker", - tasks: [ - { name: "child-a", prompt: "Task A" }, - { name: "child-b", prompt: "Task B" }, - ], - }); - - expect(result).toMatchObject({ - ok: false, - hardCapTriggered: true, - }); - expect(String(result.error)).toContain("Cannot delegate sub-agents:"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.updateStepMetadata).not.toHaveBeenCalled(); - expect(orchestratorService.startReadyAutopilotAttempts).not.toHaveBeenCalled(); - }); -}); - -describe("coordinatorTools revise_plan failure atomicity", () => { - it("returns validation errors before applying supersede or dependency mutations", async () => { - const { tools, orchestratorService, onDagMutation, graph } = createCoordinatorHarness({ - graph: { - run: { metadata: {} }, - steps: [ - { - id: "step-legacy", - stepKey: "legacy", - stepIndex: 0, - title: "Legacy", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {} - } - ], - attempts: [], - }, - }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - replaceStepKeys: ["legacy"], - replacementMap: [], - dependencyPatches: [], - reason: "Plan rewrite requested.", - newSteps: [ - { - key: "replacement", - title: "Replacement", - description: "Implement replacement task.", - dependsOn: [], - provider: "claude", - replaces: ["legacy"], - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "", - evidence: [], - maxRetries: 2 - } - } - ], - }); - - expect(result).toMatchObject({ - ok: false, - }); - expect(String(result.error)).toContain("Invalid validation contract"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.supersedeStep).not.toHaveBeenCalled(); - expect(orchestratorService.updateStepDependencies).not.toHaveBeenCalled(); - expect(orchestratorService.appendRuntimeEvent).not.toHaveBeenCalled(); - expect(orchestratorService.appendTimelineEvent).not.toHaveBeenCalled(); - expect(onDagMutation).not.toHaveBeenCalled(); - expect(graph.steps.find((entry: any) => entry.id === "step-legacy")?.status).toBe("pending"); - }); -}); - -describe("coordinatorTools delivery status", () => { - it("send_message returns delivered send status when worker session accepts message", async () => { - const sendWorkerMessageToSession = vi.fn(async () => ({ - ok: true, - delivered: true, - method: "send", - } as const)); - const graph = { - steps: [{ id: "step-1", stepKey: "worker-1" }], - attempts: [{ id: "attempt-1", stepId: "step-1", status: "running", executorSessionId: "session-1" }], - }; - const { tools } = createTestDeps({ graph, sendWorkerMessageToSession }); - - const result = await (tools.send_message as any).execute({ - workerId: "worker-1", - content: "Please focus on tests first." - }); - - expect(sendWorkerMessageToSession).toHaveBeenCalledWith({ - sessionId: "session-1", - text: "Please focus on tests first.", - priority: "urgent" - }); - expect(result).toMatchObject({ - ok: true, - delivered: true, - method: "send", - workerId: "worker-1", - sessionId: "session-1" - }); - }); - - it("send_message returns queued steer status when worker is busy", async () => { - const sendWorkerMessageToSession = vi.fn(async () => ({ - ok: true, - delivered: false, - method: "steer", - reason: "worker_busy_steered", - } as const)); - const graph = { - steps: [{ id: "step-1", stepKey: "worker-1" }], - attempts: [{ id: "attempt-1", stepId: "step-1", status: "running", executorSessionId: "session-1" }], - }; - const { tools } = createTestDeps({ graph, sendWorkerMessageToSession }); - - const result = await (tools.send_message as any).execute({ - workerId: "worker-1", - content: "Status?" - }); - - expect(result).toMatchObject({ - ok: true, - delivered: false, - method: "steer", - reason: "worker_busy_steered", - workerId: "worker-1" - }); - }); - - it("message_worker surfaces no_active_session for recipients without a running session", async () => { - const graph = { - steps: [ - { id: "step-from", stepKey: "worker-from" }, - { id: "step-to", stepKey: "worker-to" }, - ], - attempts: [{ id: "attempt-from", stepId: "step-from", status: "running", executorSessionId: "session-from" }], - }; - const { tools } = createTestDeps({ graph }); - - const result = await (tools.message_worker as any).execute({ - fromWorkerId: "worker-from", - toWorkerId: "worker-to", - content: "Can you validate this?", - priority: "normal" - }); - - expect(result).toMatchObject({ - ok: false, - delivered: false, - reason: "no_active_session" - }); - }); - - it("message_worker forwards urgent priority to worker delivery", async () => { - const sendWorkerMessageToSession = vi.fn(async () => ({ - ok: true, - delivered: true, - method: "steer", - } as const)); - const graph = { - steps: [ - { id: "step-from", stepKey: "worker-from" }, - { id: "step-to", stepKey: "worker-to" }, - ], - attempts: [ - { id: "attempt-from", stepId: "step-from", status: "running", executorSessionId: "session-from" }, - { id: "attempt-to", stepId: "step-to", status: "running", executorSessionId: "session-to" }, - ], - }; - const { tools } = createTestDeps({ graph, sendWorkerMessageToSession }); - - const result = await (tools.message_worker as any).execute({ - fromWorkerId: "worker-from", - toWorkerId: "worker-to", - content: "Drop what you're doing and validate this now.", - priority: "urgent" - }); - - expect(result).toMatchObject({ - ok: true, - delivered: true, - method: "steer", - priority: "urgent" - }); - expect(sendWorkerMessageToSession).toHaveBeenCalledWith({ - sessionId: "session-to", - text: "Drop what you're doing and validate this now.", - priority: "urgent" - }); - }); - - it("broadcast reports per-session delivery status across running attempts", async () => { - const sendWorkerMessageToSession = vi.fn(async ({ sessionId }: { sessionId: string }) => { - if (sessionId === "session-1") { - return { ok: true, delivered: true, method: "send" } as const; - } - return { - ok: true, - delivered: false, - method: "steer", - reason: "worker_busy_steered", - } as const; - }); - const graph = { - steps: [ - { id: "step-1", stepKey: "worker-1" }, - { id: "step-2", stepKey: "worker-2" }, - { id: "step-3", stepKey: "worker-3" }, - ], - attempts: [ - { id: "attempt-1", stepId: "step-1", status: "running", executorSessionId: "session-1" }, - { id: "attempt-2", stepId: "step-2", status: "running", executorSessionId: "session-2" }, - { id: "attempt-3", stepId: "step-3", status: "running", executorSessionId: null }, - ], - }; - const { tools } = createTestDeps({ graph, sendWorkerMessageToSession }); - - const result = await (tools.broadcast as any).execute({ - content: "Please pause and summarize your current state." - }); - - expect(result).toMatchObject({ - ok: true, - recipientCount: 3, - delivered: 1, - queued: 1, - failed: 1, - }); - expect(Array.isArray(result.results)).toBe(true); - expect(result.results).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - workerId: "worker-1", - ok: true, - delivered: true, - method: "send", - }), - expect.objectContaining({ - workerId: "worker-2", - ok: true, - delivered: false, - method: "steer", - reason: "worker_busy_steered", - }), - expect.objectContaining({ - workerId: "worker-3", - ok: false, - delivered: false, - reason: "no_active_session", - }), - ]) - ); - expect(sendWorkerMessageToSession).toHaveBeenCalledTimes(2); - }); -}); - -describe("coordinatorTools validation enforcement", () => { - it("mark_step_complete accepts stepId/id aliases from recovered chat-runtime tool calls", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - { - id: "step-2", - stepKey: "worker-2", - stepIndex: 1, - title: "Worker 2", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [] - }; - const { tools, db, onDagMutation } = createCoordinatorHarness({ graph }); - - const byStepId = await (tools.mark_step_complete as any).execute({ - stepId: "step-1", - summary: "Done via internal step id.", - }); - const byId = await (tools.mark_step_complete as any).execute({ - id: "worker-2", - summary: "Done via step key alias.", - }); - - expect(byStepId).toMatchObject({ - ok: true, - workerId: "worker-1", - stepId: "step-1", - }); - expect(byId).toMatchObject({ - ok: true, - workerId: "worker-2", - stepId: "step-2", - }); - expect(db.run).toHaveBeenCalledTimes(2); - expect(onDagMutation).toHaveBeenCalledWith(expect.objectContaining({ - mutation: expect.objectContaining({ stepKey: "worker-1", newStatus: "succeeded" }), - })); - expect(onDagMutation).toHaveBeenCalledWith(expect.objectContaining({ - mutation: expect.objectContaining({ stepKey: "worker-2", newStatus: "succeeded" }), - })); - }); - - it("mark_step_complete returns a helpful error when no step reference is provided", async () => { - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - }); - - const result = await (tools.mark_step_complete as any).execute({ - summary: "No reference", - }); - - expect(result).toMatchObject({ ok: false }); - expect(String(result.error)).toContain("requires workerId"); - }); - - it("mark_step_complete rejects required validation when state is not pass", async () => { - const graph = { - run: { metadata: { teamRuntime: { policyOverrides: { requireValidatorPass: true } } } }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Tests and review must pass", - evidence: [], - maxRetries: 2 - }, - validationState: "pending" - } - } - ], - attempts: [] - }; - const { tools, db, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.mark_step_complete as any).execute({ - workerId: "worker-1" - }); - - expect(result).toMatchObject({ - ok: false, - validation: { - state: "pending" - } - }); - expect(String(result.error)).toContain("requires validator pass"); - expect(String(result.hint)).toContain("report_validation"); - expect(db.run).not.toHaveBeenCalled(); - expect(orchestratorService.completeAttempt).not.toHaveBeenCalled(); - }); - - it("mark_step_complete remains blocked when validation state is fail", async () => { - const graph = { - run: { metadata: { teamRuntime: { policyOverrides: { requireValidatorPass: false } } } }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Tests and review must pass", - evidence: [], - maxRetries: 2 - }, - validationState: "fail" - } - } - ], - attempts: [] - }; - const { tools, db } = createCoordinatorHarness({ graph }); - - const result = await (tools.mark_step_complete as any).execute({ - workerId: "worker-1" - }); - - expect(result).toMatchObject({ - ok: false, - validation: { - state: "fail" - } - }); - expect(String(result.error)).toContain("requires validator pass"); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("mark_step_complete can recover a failed step after a passing validation report", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationState: "pass", - lastValidationReport: { - verdict: "pass", - summary: "Recovered output was verified.", - }, - }, - }, - ], - attempts: [] - }; - const { tools, db, onDagMutation } = createCoordinatorHarness({ graph }); - - const result = await (tools.mark_step_complete as any).execute({ - workerId: "worker-1", - summary: "Recovered after transcript validation.", - }); - - expect(result).toMatchObject({ - ok: true, - workerId: "worker-1", - stepId: "step-1", - newStatus: "succeeded", - }); - expect(db.run).toHaveBeenCalledWith( - expect.stringContaining("update orchestrator_steps set status = 'succeeded'"), - expect.arrayContaining(["step-1", "run-1"]), - ); - expect(onDagMutation).toHaveBeenCalledWith(expect.objectContaining({ - mutation: expect.objectContaining({ stepKey: "worker-1", newStatus: "succeeded" }), - })); - }); - - it("complete_mission blocks when required validation is not pass on succeeded/non-terminal steps", async () => { - const onRunFinalize = vi.fn(); - const graph = { - run: { metadata: { teamRuntime: { policyOverrides: { requireValidatorPass: true } } } }, - steps: [ - { - id: "step-succeeded", - stepKey: "impl", - stepIndex: 0, - title: "Implementation", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validation pass is required", - evidence: [], - maxRetries: 2 - }, - validationState: "fail" - } - }, - { - id: "step-skipped", - stepKey: "docs", - stepIndex: 1, - title: "Docs", - laneId: null, - status: "skipped", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Should be ignored when skipped", - evidence: [], - maxRetries: 2 - }, - validationState: "fail" - } - } - ], - attempts: [] - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, onRunFinalize }); - - const result = await (tools.complete_mission as any).execute({ - summary: "Done" - }); - - expect(result).toMatchObject({ - ok: false, - blockers: [ - expect.objectContaining({ stepKey: "impl", validationState: "fail" }) - ] - }); - expect(String(result.error)).toContain("cannot be completed"); - expect(onRunFinalize).not.toHaveBeenCalled(); - expect(orchestratorService.appendRuntimeEvent).not.toHaveBeenCalled(); - }); - - it("complete_mission remains blocked when required validation is missing", async () => { - const onRunFinalize = vi.fn(); - const graph = { - run: { metadata: { teamRuntime: { policyOverrides: { requireValidatorPass: false } } } }, - steps: [ - { - id: "step-succeeded", - stepKey: "impl", - stepIndex: 0, - title: "Implementation", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validation pass is required", - evidence: [], - maxRetries: 2 - }, - validationState: "fail" - } - } - ], - attempts: [] - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph, onRunFinalize }); - - const result = await (tools.complete_mission as any).execute({ - summary: "Done" - }); - - expect(result).toMatchObject({ - ok: false, - blockers: [ - expect.objectContaining({ stepKey: "impl", validationState: "fail" }) - ] - }); - expect(String(result.error)).toContain("cannot be completed"); - expect(onRunFinalize).not.toHaveBeenCalled(); - expect(orchestratorService.appendRuntimeEvent).not.toHaveBeenCalled(); - }); - - it("complete_mission accepts legacy passed validation aliases", async () => { - const onRunFinalize = vi.fn(); - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-succeeded", - stepKey: "impl", - stepIndex: 0, - title: "Implementation", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Validation pass is required", - evidence: [], - maxRetries: 2 - }, - validationState: "passed", - lastValidationReport: { - verdict: "passed", - summary: "Validated by an older runtime alias.", - }, - } - } - ], - attempts: [] - }; - const { tools } = createCoordinatorHarness({ graph, onRunFinalize }); - - const result = await (tools.complete_mission as any).execute({ - summary: "Done" - }); - - expect(result).toMatchObject({ ok: true }); - expect(onRunFinalize).toHaveBeenCalledWith({ - runId: "run-1", - succeeded: true, - summary: "Done", - }); - }); - - it("spawn_worker blocks phase transition when earlier required phase validation is missing", async () => { - const phaseCards = [ - { - id: "phase-build", - phaseKey: "implementation", - name: "Implementation", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true, criteria: "Validator must pass" }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }, - { - id: "phase-test", - phaseKey: "testing", - name: "Testing", - description: "Test", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["implementation"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }, - ]; - const graph = { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "testing", - currentPhaseName: "Testing", - currentPhaseModel: { - provider: "openai", - modelId: "openai/gpt-5.3-codex", - }, - } - } - }, - steps: [ - { - id: "step-build", - stepKey: "build-api", - stepIndex: 0, - title: "Build API", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - phaseKey: "implementation", - phaseName: "Implementation", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validator must pass", - evidence: [], - maxRetries: 2, - }, - validationState: "pending", - }, - } - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph, - missionMetadata: { - phaseConfiguration: { - selectedPhases: phaseCards, - } - } - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "test-followup", - prompt: "Run the testing phase follow-up", - dependsOn: ["build-api"], - }); - - expect(result).toMatchObject({ - ok: false, - error: 'Phase "Implementation" validation gate has not passed. 1 step(s) are missing required validation.', - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(orchestratorService.appendTimelineEvent).toHaveBeenCalledWith( - expect.objectContaining({ - runId: "run-1", - eventType: "validation_gate_blocked", - reason: "required_validation_gate_blocked", - }) - ); - expect(orchestratorService.appendRuntimeEvent).toHaveBeenCalledWith( - expect.objectContaining({ - runId: "run-1", - eventType: "validation_gate_blocked", - }) - ); - expect(orchestratorService.emitRuntimeUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - runId: "run-1", - reason: "validation_gate_blocked", - }) - ); - }); - - it("spawn_worker reads phase cards from run metadata when blocking validation workers outside the validation phase", async () => { - const phaseCards = [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Research", - instructions: "", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Verify", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["development"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 3, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "openai", - }, - }, - phaseConfiguration: { - selectedPhases: phaseCards, - }, - }, - }, - 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: [], - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "validate-test-tab", - prompt: "You are a validation worker. Use report_validation with verdict \"pass\" or \"fail\".", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - blockedByPhaseOrdering: true, - phaseTransitionRequired: { - phaseKey: "validation", - reason: "target_phase_required", - }, - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("spawn_worker reads raw phaseOverride arrays from run metadata", async () => { - const phaseCards = [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - { - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Verify", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["development"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "openai", - }, - }, - phaseOverride: phaseCards, - }, - }, - steps: [], - attempts: [], - }, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "validate-tab", - prompt: "You are a validation worker. Use report_validation with verdict \"pass\" or \"fail\".", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - blockedByPhaseOrdering: true, - phaseTransitionRequired: { - phaseKey: "validation", - reason: "target_phase_required", - }, - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("request_specialist fails closed when mission phases are configured but current phase is unset", async () => { - const phaseCards = [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseConfiguration: { - selectedPhases: phaseCards, - }, - teamRuntime: { - enabled: true, - template: { - roles: [ - { - name: "validator", - capabilities: ["validation"], - } - ] - } - } - } - }, - steps: [], - attempts: [], - }, - }); - - const result = await (tools.request_specialist as any).execute({ - role: "validator", - objective: "Validate the implementation.", - reason: "Need an independent pass.", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("current phase is unset"), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("set_current_phase returns a structured error when phaseKey is missing", async () => { - const { tools } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - }, - }, - steps: [], - attempts: [], - }, - missionMetadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work.", - instructions: "Plan first.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ], - }, - }, - }); - - const result = await (tools.set_current_phase as any).execute({}); - - expect(result).toEqual({ ok: false, error: "phaseKey is required." }); - }); - - it("delegate_to_subagent enforces the current phase model on explicit overrides", async () => { - const phaseCards = [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseConfiguration: { - selectedPhases: phaseCards, - }, - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "openai", - }, - }, - }, - }, - steps: [ - { - id: "step-parent", - stepKey: "parent-worker", - stepIndex: 0, - title: "Parent Worker", - laneId: "lane-parent", - status: "running", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }, - }); - - const result = await (tools.delegate_to_subagent as any).execute({ - parentWorkerId: "parent-worker", - name: "security-reviewer", - prompt: "Review auth edge cases.", - modelId: "anthropic/claude-sonnet-4-6", - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('Current phase "Development" is configured for model "openai/gpt-5.3-codex".'), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("delegate_parallel enforces the current phase model on explicit overrides", async () => { - const phaseCards = [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseConfiguration: { - selectedPhases: phaseCards, - }, - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "openai", - }, - }, - }, - }, - steps: [ - { - id: "step-parent", - stepKey: "parent-worker", - stepIndex: 0, - title: "Parent Worker", - laneId: "lane-parent", - status: "running", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }, - }); - - const result = await (tools.delegate_parallel as any).execute({ - parentWorkerId: "parent-worker", - tasks: [ - { name: "mismatch", prompt: "Review auth edge cases.", modelId: "anthropic/claude-sonnet-4-6" }, - ], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('Current phase "Development" is configured for model "openai/gpt-5.3-codex".'), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); - - it("revise_plan enforces the current phase model on new replacement steps", async () => { - const phaseCards = [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }, - ]; - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { - metadata: { - phaseConfiguration: { - selectedPhases: phaseCards, - }, - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "openai", - }, - }, - }, - }, - steps: [ - { - id: "step-legacy", - stepKey: "legacy", - stepIndex: 0, - title: "Legacy", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {} - } - ], - attempts: [], - }, - }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - replaceStepKeys: ["legacy"], - replacementMap: [], - dependencyPatches: [], - reason: "Need better plan.", - newSteps: [ - { - key: "replacement", - title: "Replacement", - description: "Implement replacement task.", - dependsOn: [], - modelId: "anthropic/claude-sonnet-4-6", - replaces: ["legacy"] - } - ], - }); - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('Current phase "Development" is configured for model "openai/gpt-5.3-codex".'), - }); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); -}); - -describe("coordinatorTools live coordinator report notifications", () => { - it("report_status emits a runtime update for the coordinator live path", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - status: "running", - laneId: "lane-1", - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_status as any).execute({ - workerId: "worker-1", - progressPct: 55, - blockers: ["waiting on integration detail"], - confidence: 0.7, - nextAction: "finish the adapter", - details: "Worker is halfway through the adapter rewrite.", - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.emitRuntimeUpdate).toHaveBeenCalledWith({ - runId: "run-1", - stepId: "step-1", - reason: "worker_status_report", - }); - }); - - it("report_result emits a runtime update for the coordinator live path", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - status: "running", - laneId: "lane-1", - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_result as any).execute({ - workerId: "worker-1", - outcome: "succeeded", - summary: "Finished the adapter rewrite and updated tests.", - artifacts: [], - filesChanged: ["src/adapter.ts", "src/adapter.test.ts"], - testsRun: { - command: "npm test -- adapter", - passed: 4, - failed: 0, - skipped: 0, - }, - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.emitRuntimeUpdate).toHaveBeenCalledWith({ - runId: "run-1", - stepId: "step-1", - reason: "worker_result_report", - }); - }); - - it("report_validation emits a runtime update for the coordinator live path", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-target", - stepKey: "worker-target", - stepIndex: 0, - title: "Target worker", - status: "succeeded", - laneId: "lane-1", - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validation must pass.", - evidence: [], - maxRetries: 2, - }, - }, - }, - { - id: "step-validator", - stepKey: "validator-1", - stepIndex: 1, - title: "Validator", - status: "running", - laneId: "lane-1", - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_validation as any).execute({ - validatorWorkerId: "validator-1", - targetWorkerId: "worker-target", - verdict: "pass", - summary: "Validation passed cleanly.", - findings: [], - remediationInstructions: [], - }); - - expect(result).toMatchObject({ ok: true }); - expect(orchestratorService.emitRuntimeUpdate).toHaveBeenCalledWith({ - runId: "run-1", - stepId: "step-target", - reason: "validation_report", - }); - }); - - it("report_validation accepts stepKey aliases from recovered chat-runtime calls", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-target", - stepKey: "worker-target", - stepIndex: 0, - title: "Target worker", - status: "failed", - laneId: "lane-1", - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_validation as any).execute({ - stepKey: "worker-target", - verdict: "pass", - summary: "Recovered worker output passed validation.", - findings: [], - remediationInstructions: [], - }); - - expect(result).toMatchObject({ - ok: true, - recommendedAction: "mark_step_complete", - report: { - scope: { - stepId: "step-target", - stepKey: "worker-target", - }, - }, - }); - expect(orchestratorService.updateStepMetadata).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - stepId: "step-target", - allowTerminal: true, - })); - }); - - it("report_validation normalizes passed/failed verdict aliases from recovered chat-runtime calls", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-target", - stepKey: "worker-target", - stepIndex: 0, - title: "Target worker", - status: "succeeded", - laneId: "lane-1", - metadata: { - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Self validation must pass.", - evidence: [], - maxRetries: 2, - }, - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_validation as any).execute({ - stepKey: "worker-target", - verdict: "passed", - summary: "Recovered validation passed.", - findings: [], - remediationInstructions: [], - }); - - expect(result).toMatchObject({ - ok: true, - report: { - verdict: "pass", - }, - }); - expect(orchestratorService.updateStepMetadata).toHaveBeenCalledWith(expect.objectContaining({ - metadata: expect.objectContaining({ - validationState: "pass", - validationPassedAt: expect.any(String), - lastValidationReport: expect.objectContaining({ verdict: "pass" }), - }), - })); - }); - - it("report_validation redirects auto-spawned validator reports to the target step", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-target", - stepKey: "worker-target", - stepIndex: 0, - title: "Target worker", - status: "succeeded", - laneId: "lane-1", - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Target validation must pass.", - evidence: [], - maxRetries: 2, - }, - }, - }, - { - id: "step-validator", - stepKey: "validate_worker-target", - stepIndex: 1, - title: "Validate target worker", - status: "running", - laneId: "lane-1", - metadata: { - autoSpawnedValidation: true, - targetStepId: "step-target", - targetStepKey: "worker-target", - }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_validation as any).execute({ - stepKey: "validate_worker-target", - verdict: "pass", - summary: "Auto validation passed.", - findings: [], - remediationInstructions: [], - }); - - expect(result).toMatchObject({ - ok: true, - report: { - scope: { - stepId: "step-target", - stepKey: "worker-target", - }, - validatorWorkerId: "validate_worker-target", - }, - }); - expect(orchestratorService.updateStepMetadata).toHaveBeenCalledWith(expect.objectContaining({ - stepId: "step-target", - metadata: expect.objectContaining({ - validationState: "pass", - lastValidationReport: expect.objectContaining({ - validatorWorkerId: "validate_worker-target", - }), - }), - })); - }); -}); - -describe("coordinatorTools insert_milestone", () => { - it("creates a milestone step and gates requested steps without auto-starting workers", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-a", - stepKey: "impl-a", - stepIndex: 0, - title: "Implementation A", - laneId: null, - status: "succeeded", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {} - }, - { - id: "step-b", - stepKey: "impl-b", - stepIndex: 1, - title: "Implementation B", - laneId: null, - status: "pending", - dependencyStepIds: ["step-a"], - retryLimit: 1, - retryCount: 0, - metadata: {} - } - ], - attempts: [] - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.insert_milestone as any).execute({ - name: "API Milestone", - dependsOn: ["impl-a"], - validationCriteria: "Integration tests pass and validator signs off", - gatesSteps: ["impl-b"] - }); - - expect(result).toMatchObject({ - ok: true, - milestone: { - name: "API Milestone", - validationContract: { - level: "milestone", - tier: "dedicated", - required: true, - criteria: "Integration tests pass and validator signs off", - maxRetries: 2, - evidence: [] - } - }, - gatesStepsPatched: [ - expect.objectContaining({ stepKey: "impl-b" }) - ] - }); - expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ - runId: "run-1", - steps: [ - expect.objectContaining({ - executorKind: "manual", - metadata: expect.objectContaining({ - stepType: "milestone", - isMilestone: true, - milestoneValidationCriteria: "Integration tests pass and validator signs off", - validationContract: expect.objectContaining({ - level: "milestone", - tier: "dedicated", - required: true - }) - }) - }) - ] - })); - expect(orchestratorService.updateStepDependencies).toHaveBeenCalledWith({ - runId: "run-1", - stepId: "step-b", - dependencyStepKeys: ["impl-a", result.milestone.stepKey] - }); - expect(orchestratorService.startReadyAutopilotAttempts).not.toHaveBeenCalled(); - }); - - it("tolerates omitted dependency arrays from provider tool calls", async () => { - const graph = { - run: { metadata: {} }, - steps: [], - attempts: [] - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.insert_milestone as any).execute({ - name: "API Milestone", - validationCriteria: "Integration tests pass and validator signs off", - }); - - expect(result).toMatchObject({ - ok: true, - milestone: expect.objectContaining({ - name: "API Milestone", - }), - dependsOn: [], - gatesStepsPatched: [], - }); - expect(orchestratorService.addSteps).toHaveBeenCalledTimes(1); - }); -}); - -describe("coordinatorTools report_validation milestone behavior", () => { - it("marks milestone step succeeded when validation passes", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-ms", - stepKey: "milestone-auth", - stepIndex: 2, - title: "Auth milestone", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: { - isMilestone: true, - validationContract: { - level: "milestone", - tier: "dedicated", - required: true, - criteria: "Auth tests pass", - evidence: [], - maxRetries: 2, - }, - }, - }, - ], - attempts: [], - }; - const { tools, db, onDagMutation } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_validation as any).execute({ - targetWorkerId: "milestone-auth", - contract: { - level: "milestone", - tier: "dedicated", - required: true, - criteria: "Auth tests pass", - evidence: [], - maxRetries: 2, - }, - verdict: "pass", - summary: "Milestone validation passed", - findings: [], - remediationInstructions: [], - }); - - expect(result).toMatchObject({ - ok: true, - maxRetriesExceeded: false, - milestoneMarkedComplete: true, - }); - expect(db.run).toHaveBeenCalledWith( - expect.stringContaining("update orchestrator_steps set status = 'succeeded'"), - expect.any(Array), - ); - expect(onDagMutation).toHaveBeenCalledWith( - expect.objectContaining({ - mutation: expect.objectContaining({ - type: "status_changed", - stepKey: "milestone-auth", - newStatus: "succeeded", - }), - }), - ); - }); - - it("opens intervention when required validation retries are exhausted", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-api", - stepKey: "api-contracts", - stepIndex: 3, - title: "API contracts", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 1, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Contract tests pass", - evidence: [], - maxRetries: 1, - }, - }, - }, - ], - attempts: [], - }; - const { tools, missionService } = createCoordinatorHarness({ graph }); - - const result = await (tools.report_validation as any).execute({ - targetWorkerId: "api-contracts", - contract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Contract tests pass", - evidence: [], - maxRetries: 1, - }, - verdict: "fail", - summary: "Contract checks failed", - findings: [ - { - code: "contracts.broken", - severity: "high", - message: "Breaking response schema change detected", - }, - ], - remediationInstructions: ["Restore backwards-compatible API schema"], - retriesUsed: 1, - }); - - expect(result).toMatchObject({ - ok: true, - maxRetriesExceeded: true, - recommendedAction: "escalate_human_or_replan", - }); - expect(result.interventionId).toBeTruthy(); - expect(missionService.addIntervention).toHaveBeenCalledTimes(1); - }); -}); - -describe("coordinatorTools reflection_add", () => { - it("records reflection entries with inferred worker scope", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-impl", - stepKey: "impl-worker", - stepIndex: 0, - title: "Implementation", - laneId: null, - status: "running", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [ - { - id: "attempt-running", - stepId: "step-impl", - status: "running", - createdAt: "2026-03-05T00:00:00.000Z", - completedAt: null, - }, - ], - }; - const { tools, orchestratorService } = createCoordinatorHarness({ graph }); - - const result = await (tools.reflection_add as any).execute({ - workerId: "impl-worker", - phase: "development", - signalType: "frustration", - observation: "Typecheck loop is slow", - recommendation: "Use incremental mode", - context: "editing auth flow", - }); - - expect(result).toMatchObject({ - ok: true, - reflection: expect.objectContaining({ id: "reflection-1" }) - }); - expect(orchestratorService.addReflection).toHaveBeenCalledWith( - expect.objectContaining({ - missionId: "mission-1", - runId: "run-1", - stepId: "step-impl", - attemptId: "attempt-running", - phase: "development", - signalType: "frustration", - observation: "Typecheck loop is slow", - recommendation: "Use incremental mode", - context: "editing auth flow", - agentRole: "coordinator" - }) - ); - }); -}); - -describe("coordinatorTools retry_step safety", () => { - it("rejects retry when an attempt is still running to prevent overlapping execution", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 1, - metadata: { instructions: "old" }, - }, - ], - attempts: [ - { - id: "attempt-running", - stepId: "step-1", - status: "running", - executorSessionId: "session-1", - }, - ], - }; - const { tools, db } = createCoordinatorHarness({ graph }); - - const result = await (tools.retry_step as any).execute({ - workerId: "worker-1", - adjustedInstructions: "Try again with stricter validation.", - }); - - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("still running"); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("requires a failed source step before retrying", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 0, - metadata: { instructions: "old" }, - }, - ], - attempts: [], - }; - const { tools, db } = createCoordinatorHarness({ graph }); - - const result = await (tools.retry_step as any).execute({ - workerId: "worker-1", - adjustedInstructions: "Retry instructions.", - }); - - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("must be failed before retry"); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("rejects retry after the retry limit is exhausted", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 0, - retryCount: 0, - metadata: { instructions: "old" }, - }, - ], - attempts: [], - }; - const { tools, db } = createCoordinatorHarness({ graph }); - - const result = await (tools.retry_step as any).execute({ - workerId: "worker-1", - adjustedInstructions: "Retry instructions.", - }); - - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("exhausted its retry limit"); - expect(db.run).not.toHaveBeenCalled(); - }); - - it("grants one extra retry when the exhausted failure was orphaned by an app restart", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 1, - lastAttemptId: "attempt-orphaned", - metadata: { instructions: "old" }, - }, - ], - attempts: [ - { - id: "attempt-orphaned", - stepId: "step-1", - status: "failed", - errorMessage: "Worker attempt was orphaned by app restart; no files changed.", - }, - ], - }; - const { tools, db } = createCoordinatorHarness({ graph }); - - const result = await (tools.retry_step as any).execute({ - workerId: "worker-1", - adjustedInstructions: "Retry with the same scoped work after app restart.", - }); - - expect(result).toEqual(expect.objectContaining({ - ok: true, - retryCount: 2, - retryLimit: 2, - infraRetryLimitExtended: true, - })); - expect(db.run).toHaveBeenCalledWith( - expect.stringContaining("retry_limit = ?"), - expect.arrayContaining([2, expect.any(String), "step-1", "run-1"]), - ); - }); -}); - -describe("coordinatorTools revise_plan validation atomicity", () => { - it("tolerates omitted replacement and dependency arrays from provider tool calls", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { metadata: {} }, - steps: [], - attempts: [], - }, - missionLaneId: "lane-mission", - }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - reason: "Need a minimal plan revision.", - newSteps: [ - { - key: "implement-sidebar", - title: "Implement sidebar", - description: "Add the test tab and placeholder screen.", - } - ], - }); - - expect(result).toMatchObject({ - ok: true, - newStepKeys: ["implement-sidebar"], - }); - expect(orchestratorService.addSteps).toHaveBeenCalled(); - }); - - it("validates replacementMap targets before creating any new steps", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-old", - stepKey: "old-worker", - stepIndex: 0, - title: "Old worker", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService, graph: mutableGraph, onDagMutation } = createCoordinatorHarness({ graph }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - replaceStepKeys: ["old-worker"], - replacementMap: [{ oldStepKey: "old-worker", newStepKey: "missing-new-step" }], - dependencyPatches: [], - reason: "Need a better worker split", - newSteps: [ - { - key: "new-worker", - title: "New worker", - description: "Implement replacement flow", - dependsOn: [], - replaces: ["old-worker"], - }, - ], - }); - - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("replacementMap references unknown newStepKey"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(mutableGraph.steps).toHaveLength(1); - expect(onDagMutation).not.toHaveBeenCalled(); - }); - - it("validates dependency patches before mutating the graph", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-old", - stepKey: "old-worker", - stepIndex: 0, - title: "Old worker", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService, graph: mutableGraph } = createCoordinatorHarness({ graph }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - replaceStepKeys: ["old-worker"], - replacementMap: [], - dependencyPatches: [ - { - stepKey: "new-worker", - dependencyStepKeys: ["unknown-dependency"], - }, - ], - reason: "Rewire dependencies", - newSteps: [ - { - key: "new-worker", - title: "New worker", - description: "Implement replacement flow", - dependsOn: [], - replaces: ["old-worker"], - }, - ], - }); - - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("unknown dependency keys"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(mutableGraph.steps).toHaveLength(1); - }); -}); - -describe("coordinatorTools hard-cap fail-closed behavior", () => { - it("blocks spawn_worker when budget telemetry fails", async () => { - const onHardCapTriggered = vi.fn(); - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - getMissionBudgetStatus: async () => { - throw new Error("telemetry offline"); - }, - onHardCapTriggered, - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "blocked-worker", - provider: "claude", - prompt: "Do work", - dependsOn: [], - }); - - expect(result).toMatchObject({ - ok: false, - hardCapTriggered: true, - }); - expect(String(result.error)).toContain("Budget telemetry unavailable"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - expect(onHardCapTriggered).toHaveBeenCalledWith(expect.stringContaining("Budget telemetry unavailable")); - }); - - it("blocks revise_plan spawning when budget telemetry fails", async () => { - const { tools, orchestratorService } = createCoordinatorHarness({ - graph: { - run: { metadata: {} }, - steps: [ - { - id: "step-old", - stepKey: "old-worker", - stepIndex: 0, - title: "Old worker", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }, - getMissionBudgetStatus: async () => { - throw new Error("telemetry unavailable"); - }, - }); - - const result = await (tools.revise_plan as any).execute({ - mode: "partial", - replaceStepKeys: ["old-worker"], - replacementMap: [], - dependencyPatches: [], - reason: "Need alternate approach", - newSteps: [ - { - key: "new-worker", - title: "New worker", - description: "Replacement implementation", - dependsOn: [], - replaces: ["old-worker"], - }, - ], - }); - - expect(result).toMatchObject({ - ok: false, - hardCapTriggered: true, - }); - expect(String(result.error)).toContain("Budget telemetry unavailable"); - expect(orchestratorService.addSteps).not.toHaveBeenCalled(); - }); -}); - -describe("coordinatorTools file path containment", () => { - it("read_file rejects absolute paths that only share a root prefix", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-root-")); - const siblingRoot = `${projectRoot}-evil`; - fs.mkdirSync(siblingRoot, { recursive: true }); - const outsideFile = path.join(siblingRoot, "secret.txt"); - fs.writeFileSync(outsideFile, "leak", "utf-8"); - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.read_file as any).execute({ - filePath: outsideFile, - }); - - expect(result).toMatchObject({ - ok: false, - error: "Path is outside mission workspace root", - }); - }); - - it("read_file rejects symlinked files that point outside the workspace", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-symlink-root-")); - const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-symlink-outside-")); - const outsideFile = path.join(outsideRoot, "secret.txt"); - fs.writeFileSync(outsideFile, "leak", "utf-8"); - fs.symlinkSync(outsideFile, path.join(projectRoot, "linked-secret.txt")); - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.read_file as any).execute({ - filePath: "linked-secret.txt", - }); - - expect(result).toMatchObject({ - ok: false, - error: "Path is outside mission workspace root", - }); - }); - - it("read_file sanitizes missing in-workspace files", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-missing-root-")); - try { - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.read_file as any).execute({ - filePath: "missing.txt", - }); - - expect(result.ok).toBe(false); - expect(result.error).toBe("file not found: missing.txt"); - expect(String(result.error)).not.toContain(projectRoot); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("search_files skips symlinked files that point outside the workspace", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-symlink-root-")); - const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-symlink-outside-")); - const outsideFile = path.join(outsideRoot, "secret.txt"); - fs.writeFileSync(outsideFile, "leak me", "utf-8"); - fs.symlinkSync(outsideFile, path.join(projectRoot, "linked-secret.txt")); - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.search_files as any).execute({ - pattern: "leak me", - searchType: "content", - maxResults: 10, - }); - - expect(result).toMatchObject({ - ok: true, - total: 0, - }); - }); - - it("search_files matches filename globs against the full relative path", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-glob-root-")); - fs.mkdirSync(path.join(projectRoot, "src", "nested"), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, "tests"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "src", "nested", "match.ts"), "export const value = 1;\n", "utf-8"); - fs.writeFileSync(path.join(projectRoot, "tests", "match.ts"), "export const value = 2;\n", "utf-8"); - fs.writeFileSync(path.join(projectRoot, "src", "match.js"), "export const value = 3;\n", "utf-8"); - - try { - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.search_files as any).execute({ - pattern: "src/**/*.ts", - searchType: "filename", - maxResults: 10, - }); - - expect(result).toMatchObject({ - ok: true, - total: 1, - results: ["src/nested/match.ts"], - }); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("search_files treats unsafe content regex patterns as literals", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-regex-root-")); - fs.mkdirSync(path.join(projectRoot, "docs"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "notes.txt"), "literal (a+)+$ pattern\n", "utf-8"); - - try { - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.search_files as any).execute({ - pattern: "(a+)+$", - searchType: "content", - maxResults: 10, - }); - - expect(result).toMatchObject({ - ok: true, - total: 1, - }); - expect(result.results).toEqual([ - expect.objectContaining({ - file: "docs/notes.txt", - line: 1, - }), - ]); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("search_files supports explicit regex mode for safe patterns", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-explicit-regex-root-")); - fs.mkdirSync(path.join(projectRoot, "docs"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "notes.txt"), "hello world\n", "utf-8"); - - try { - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.search_files as any).execute({ - pattern: "^hello\\s+world$", - searchType: "content", - explicitRegexMode: true, - maxResults: 10, - }); - - expect(result).toMatchObject({ - ok: true, - total: 1, - }); - expect(result.results).toEqual([ - expect.objectContaining({ - file: "docs/notes.txt", - line: 1, - }), - ]); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("read_step_output sanitizes traversal-like keys and reads only project-scoped output", async () => { - 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}-attempt-${attemptId}.md`); - fs.writeFileSync(scopedFile, "scoped output", "utf-8"); - - const { tools } = createCoordinatorHarness({ - graph: { - run: { metadata: {} }, - steps: [{ id: "step-sensitive", stepKey: maliciousKey }], - attempts: [{ id: attemptId, stepId: "step-sensitive", createdAt: "2026-01-01T00:00:00.000Z" }], - }, - projectRoot, - }); - - const result = await (tools.read_step_output as any).execute({ - stepKey: maliciousKey, - }); - - expect(result).toMatchObject({ - ok: true, - stepKey: maliciousKey, - content: "scoped output", - }); - }); - - it("read_step_output reports missing files without leaking resolved paths", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-step-output-missing-root-")); - - try { - const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - projectRoot, - }); - - const result = await (tools.read_step_output as any).execute({ - stepKey: "missing-step", - }); - - expect(result.ok).toBe(false); - expect(result.error).toBe("file not found: missing-step"); - expect(String(result.error)).not.toContain(projectRoot); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); -}); - -describe("coordinatorTools completion DAG events", () => { - it("fail_mission forces a failed run state even when completion gates would reject finalization", async () => { - const onRunFinalize = vi.fn(); - const { tools, db, missionService } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, - onRunFinalize, - }); - - const result = await (tools.fail_mission as any).execute({ - reason: "No runnable recovery path remains.", - }); - - expect(result).toMatchObject({ ok: true, runId: "run-1" }); - expect(onRunFinalize).toHaveBeenCalledWith({ - runId: "run-1", - succeeded: false, - reason: "No runnable recovery path remains.", - }); - expect(db.run).toHaveBeenCalledWith( - expect.stringContaining("set status = 'failed'"), - expect.arrayContaining(["No runnable recovery path remains.", "run-1", "project-1"]), - ); - expect(missionService.update).toHaveBeenCalledWith({ - missionId: "mission-1", - status: "failed", - lastError: "No runnable recovery path remains.", - outcomeSummary: "No runnable recovery path remains.", - }); - }); - - it("can skip a failed step when the coordinator decides the branch is superseded", async () => { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-failed", - stepKey: "failed-worker", - stepIndex: 0, - title: "Failed Worker", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 1, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, orchestratorService, onDagMutation } = createCoordinatorHarness({ graph }); - - const result = await (tools.skip_step as any).execute({ - workerId: "failed-worker", - reason: "Superseded by replacement validation.", - }); - - expect(result).toMatchObject({ ok: true, workerId: "failed-worker", newStatus: "skipped" }); - expect(orchestratorService.skipStep).toHaveBeenCalledWith({ - runId: "run-1", - stepId: "step-failed", - reason: "Superseded by replacement validation.", - allowTerminal: true, - }); - expect(onDagMutation).toHaveBeenCalledWith(expect.objectContaining({ - mutation: { type: "status_changed", stepKey: "failed-worker", newStatus: "skipped" }, - })); - }); - - it("does not skip pending/ready/blocked steps when complete_mission is blocked by runtime gates", async () => { - const graph = { - run: { metadata: { teamRuntime: { policyOverrides: { requireValidatorPass: false } } } }, - steps: [ - { - id: "step-pending", - stepKey: "pending-step", - stepIndex: 0, - title: "Pending", - laneId: null, - status: "pending", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - { - id: "step-ready", - stepKey: "ready-step", - stepIndex: 1, - title: "Ready", - laneId: null, - status: "ready", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - { - id: "step-blocked", - stepKey: "blocked-step", - stepIndex: 2, - title: "Blocked", - laneId: null, - status: "blocked", - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - metadata: {}, - }, - ], - attempts: [], - }; - const { tools, onDagMutation, orchestratorService } = createCoordinatorHarness({ - graph, - finalizeRunResult: { - finalized: false, - blockers: ["Required phase \"Development\" has not succeeded yet."], - finalStatus: "completing", - }, - }); - - const result = await (tools.complete_mission as any).execute({ summary: "Mission complete" }); - - expect(result).toMatchObject({ - ok: false, - blockers: ["Required phase \"Development\" has not succeeded yet."], - }); - const skipEvents = onDagMutation.mock.calls - .map((call) => call[0]?.mutation) - .filter((mutation: any) => mutation?.type === "status_changed" && mutation?.newStatus === "skipped"); - expect(skipEvents).toHaveLength(0); - expect(orchestratorService.finalizeRun).toHaveBeenCalledWith({ runId: "run-1" }); - }); -}); - -describe("coordinatorTools autopilot scheduling logging", () => { - it("logs debug context when retry_step autopilot scheduling fails", async () => { - vi.useFakeTimers(); - try { - const graph = { - run: { metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - stepIndex: 0, - title: "Worker 1", - laneId: null, - status: "failed", - dependencyStepIds: [], - retryLimit: 2, - retryCount: 1, - metadata: { instructions: "old" }, - }, - ], - attempts: [], - }; - const { tools, orchestratorService, logger } = createCoordinatorHarness({ graph }); - orchestratorService.startReadyAutopilotAttempts.mockRejectedValueOnce(new Error("queue unavailable")); - - const result = await (tools.retry_step as any).execute({ - workerId: "worker-1", - adjustedInstructions: "Retry with safer ordering.", - }); - - expect(result.ok).toBe(true); - await vi.runAllTimersAsync(); - expect(logger.debug).toHaveBeenCalledWith( - "coordinator.retry_step.autopilot_schedule_failed", - expect.objectContaining({ - runId: "run-1", - workerId: "worker-1", - error: "queue unavailable", - }) - ); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts deleted file mode 100644 index 9d3442609..000000000 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ /dev/null @@ -1,7677 +0,0 @@ -// --------------------------------------------------------------------------- -// Coordinator Tool Set — AI-first tools for the orchestrator brain. -// Each tool is a thin wrapper around persistence + worker spawning. -// The AI decides everything; tools just execute its decisions. -// --------------------------------------------------------------------------- - -import { z } from "zod"; -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import type { createOrchestratorService } from "./orchestratorService"; -import type { - DelegationContract, - DelegationIntent, - DelegationMode, - MissionBudgetSnapshot, - MissionBudgetHardCapStatus, - MissionStateDecision, - MissionStateDocumentPatch, - MissionStateIssue, - MissionStateProgress, - MissionStateStepOutcome, - MissionStateStepOutcomePartial, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorAttempt, - DagMutationEvent, - TeamRuntimeConfig, - RoleDefinition, - WorkerStatusReport, - WorkerResultReport, - ValidationContract, - ValidationResultReport, - PhaseCard, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { createMissionService } from "../missions/missionService"; -import { - asRecord, - filterExecutionSteps, - isDisplayOnlyTaskStep, - nowIso, - TERMINAL_STEP_STATUSES, -} from "./orchestratorContext"; -import { readMissionStateDocument, updateMissionStateDocument } from "./missionStateDoc"; -import { escapeRegExp, resolvePathWithinRoot } from "../shared/utils"; -import { normalizeAgentRuntimeFlags } from "./teamRuntimeConfig"; -import { registerTeamMember } from "./teamRuntimeState"; -import { - classifyWorkerExecutionPath, - resolveModelDescriptor, - resolveProviderGroupForModel, -} from "../../../shared/modelRegistry"; -import { - checkCoordinatorToolPermission, - createDelegationContract, - createDelegationScope, - extractActiveDelegationContracts, - hasConflictingDelegationContract, - updateDelegationContract, -} from "./delegationContracts"; -import { - findPhaseByRef, - resolveFirstPostPlanningPhaseKey, - resolveMustPrecedePredecessors, -} from "../missions/phaseEngine"; -import { - hasResolvedPlanningQuestionIntervention, - isPlanningQuestionIntervention, - phaseRequiresPlanningQuestionBeforeExit, -} from "./planningQuestionPolicy"; - -/** Timeout for autopilot agent startup (Promise.race guard). */ -const AUTOPILOT_START_TIMEOUT_MS = 15_000; - -const VALIDATION_CONTRACT_SCHEMA = z - .object({ - level: z.enum(["step", "milestone", "mission"]), - tier: z.enum(["self", "dedicated"]), - required: z.boolean(), - criteria: z.string(), - evidence: z.array(z.string()).default([]), - maxRetries: z.number().int().min(0).max(10).default(1), - }) - .optional(); - -const STEP_OUTCOME_SCHEMA = z.object({ - stepKey: z.string(), - stepName: z.string(), - phase: z.string(), - status: z.enum(["succeeded", "failed", "skipped", "in_progress"]), - summary: z.string(), - filesChanged: z.array(z.string()).default([]), - testsRun: z - .object({ - passed: z.number().int().min(0), - failed: z.number().int().min(0), - skipped: z.number().int().min(0), - }) - .optional(), - validation: z - .object({ - verdict: z.enum(["pass", "fail"]).nullable(), - findings: z.array(z.string()).default([]), - }) - .optional(), - warnings: z.array(z.string()).default([]), - completedAt: z.string().nullable(), -}); - -const STEP_OUTCOME_PARTIAL_SCHEMA = z.object({ - stepName: z.string().optional(), - phase: z.string().optional(), - status: z.enum(["succeeded", "failed", "skipped", "in_progress"]).optional(), - summary: z.string().optional(), - filesChanged: z.array(z.string()).optional(), - testsRun: z - .object({ - passed: z.number().int().min(0).optional(), - failed: z.number().int().min(0).optional(), - skipped: z.number().int().min(0).optional(), - }) - .optional(), - validation: z - .object({ - verdict: z.enum(["pass", "fail"]).nullable().optional(), - findings: z.array(z.string()).optional(), - }) - .optional(), - warnings: z.array(z.string()).optional(), - completedAt: z.string().nullable().optional(), -}); - -const RUNNING_WORKER_OUTPUT_RECHECK_COOLDOWN_MS = 15_000; - -function isAppRestartOrphanAttempt(attempt: Pick<OrchestratorAttempt, "errorMessage"> | null | undefined): boolean { - const message = String(attempt?.errorMessage ?? "").toLowerCase(); - return message.includes("orphaned by app restart") - || message.includes("closed during shutdown") - || message.includes("worker session was lost"); -} - -const DECISION_SCHEMA = z.object({ - timestamp: z.string(), - decision: z.string(), - rationale: z.string(), - context: z.string(), -}); - -const ISSUE_SCHEMA = z.object({ - id: z.string(), - severity: z.enum(["low", "medium", "high"]), - description: z.string(), - affectedSteps: z.array(z.string()).default([]), - status: z.enum(["open", "mitigated", "resolved"]), -}); - -const PROGRESS_PARTIAL_SCHEMA = z.object({ - currentPhase: z.string().optional(), - completedSteps: z.number().int().min(0).optional(), - totalSteps: z.number().int().min(0).optional(), - activeWorkers: z.array(z.string()).optional(), - blockedSteps: z.array(z.string()).optional(), - failedSteps: z.array(z.string()).optional(), -}); - -export const COORDINATOR_TOOL_NAMES = [ - "spawn_worker", - "insert_milestone", - "request_specialist", - "delegate_to_subagent", - "delegate_parallel", - "stop_worker", - "send_message", - "message_worker", - "broadcast", - "get_worker_output", - "list_workers", - "report_status", - "reflection_add", - "report_result", - "report_validation", - "read_mission_status", - "read_mission_state", - "update_mission_state", - "revise_plan", - "update_tool_profiles", - "transfer_lane", - "provision_lane", - "set_current_phase", - "create_task", - "update_task", - "assign_task", - "list_tasks", - "skip_step", - "mark_step_complete", - "mark_step_failed", - "retry_step", - "complete_mission", - "fail_mission", - "get_budget_status", - "ask_user", - "request_user_input", - "read_file", - "read_step_output", - "search_files", - "get_project_context", - "check_finalization_status", -] as const; - -export type PlannerLaunchFailureCategory = - | "run_context_bug" - | "provider_unreachable" - | "permission_denied" - | "tool_schema_error" - | "unknown"; - -export type PlannerLaunchFailureClassification = { - category: PlannerLaunchFailureCategory; - interventionType: "provider_unreachable" | "policy_block" | "unrecoverable_error" | "failed_step"; - reasonCode: string; - retryable: boolean; - recoveryOptions: Array<"retry" | "switch_to_fallback_model" | "cancel_run">; -}; - -export function classifyPlannerLaunchFailure(error: unknown): PlannerLaunchFailureClassification { - const message = typeof error === "string" - ? error.trim() - : error instanceof Error - ? error.message.trim() - : String(error ?? "").trim(); - const normalized = message.toLowerCase(); - - if ( - normalized.includes("run not found") - || normalized.includes("mission not found") - || normalized.includes("project not found") - || normalized.includes("caller context") - || normalized.includes("missing run context") - ) { - return { - category: "run_context_bug", - interventionType: "unrecoverable_error", - reasonCode: "planner_launch_run_context_bug", - retryable: false, - recoveryOptions: ["cancel_run"], - }; - } - - if ( - normalized.includes("requires approval") - || normalized.includes("permission denied") - || normalized.includes("policy block") - || normalized.includes("not allowed") - || normalized.includes("approval denied") - ) { - return { - category: "permission_denied", - interventionType: "policy_block", - reasonCode: "planner_launch_permission_denied", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - }; - } - - if ( - normalized.includes("inputvalidationerror") - || normalized.includes("invalid input") - || normalized.includes("unexpected command") - || normalized.includes("schema") - || normalized.includes("validation error") - ) { - return { - category: "tool_schema_error", - interventionType: "failed_step", - reasonCode: "planner_launch_tool_schema_error", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - }; - } - - if ( - normalized.includes("rate limit") - || normalized.includes("timed out") - || normalized.includes("timeout") - || normalized.includes("temporarily unavailable") - || normalized.includes("connection refused") - || normalized.includes("network") - || normalized.includes("provider") - || normalized.includes("model") - || normalized.includes("api key") - || normalized.includes("authentication") - || normalized.includes("unauthorized") - ) { - return { - category: "provider_unreachable", - interventionType: "provider_unreachable", - reasonCode: "planner_launch_provider_unreachable", - retryable: true, - recoveryOptions: ["retry", "switch_to_fallback_model", "cancel_run"], - }; - } - - return { - category: "unknown", - interventionType: "failed_step", - reasonCode: "planner_launch_unknown_failure", - retryable: false, - recoveryOptions: ["retry", "cancel_run"], - }; -} - -export type CoordinatorWorkerDeliveryStatus = - | { ok: true; delivered: true; method: "send" | "steer" } - | { ok: true; delivered: false; reason: "worker_busy_steered"; method: "steer" } - | { ok: false; delivered: false; reason: "no_active_session" | "delivery_failed"; error?: string }; - -export type CoordinatorSendWorkerMessageFn = (args: { - sessionId: string; - text: string; - priority?: "normal" | "urgent"; -}) => Promise<CoordinatorWorkerDeliveryStatus>; - -export type CoordinatorExecutableTool = { - description: string; - inputSchema: z.ZodTypeAny; - execute: (args: Record<string, unknown>) => Promise<unknown>; -}; - -function defineCoordinatorTool<TSchema extends z.ZodTypeAny>(definition: { - description: string; - inputSchema: TSchema; - execute: (args: z.infer<TSchema>) => Promise<unknown>; -}): CoordinatorExecutableTool { - return { - description: definition.description, - inputSchema: definition.inputSchema, - execute: async (args: Record<string, unknown>) => await definition.execute(args as z.infer<TSchema>), - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function resolveStep( - graph: OrchestratorRunGraph, - stepRef: string, -): OrchestratorStep | null { - const normalizedRef = normalizeText(stepRef); - if (!normalizedRef.length) return null; - return graph.steps.find((s) => s.stepKey === normalizedRef || s.id === normalizedRef) ?? null; -} - -function findRunningAttempt( - graph: OrchestratorRunGraph, - stepId: string, -): OrchestratorAttempt | null { - return ( - graph.attempts.find( - (a) => a.stepId === stepId && a.status === "running", - ) ?? null - ); -} - -function resolveCurrentPhase(graph: OrchestratorRunGraph): string { - const runMeta = asRecord(graph.run.metadata); - const phaseRuntime = asRecord(runMeta?.phaseRuntime); - const phaseName = typeof phaseRuntime?.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - if (phaseName.length > 0) return phaseName; - const phaseKey = typeof phaseRuntime?.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - if (phaseKey.length > 0) return phaseKey; - const relevantSteps = filterExecutionSteps(graph.steps); - const activeStep = relevantSteps.find((step) => !TERMINAL_STEP_STATUSES.has(step.status)) ?? null; - const stepMeta = asRecord(activeStep?.metadata); - const fromStepName = typeof stepMeta?.phaseName === "string" ? stepMeta.phaseName.trim() : ""; - if (fromStepName.length > 0) return fromStepName; - const fromStepKey = typeof stepMeta?.phaseKey === "string" ? stepMeta.phaseKey.trim() : ""; - if (fromStepKey.length > 0) return fromStepKey; - return "unknown"; -} - -function buildMissionStateProgress(graph: OrchestratorRunGraph): MissionStateProgress { - const relevantSteps = filterExecutionSteps(graph.steps); - let completedSteps = 0; - const activeWorkers: string[] = []; - const blockedSteps: string[] = []; - const failedSteps: string[] = []; - for (const step of relevantSteps) { - if (TERMINAL_STEP_STATUSES.has(step.status)) { - completedSteps += 1; - } - if (step.status === "running") { - activeWorkers.push(step.stepKey); - } else if (step.status === "blocked") { - blockedSteps.push(step.stepKey); - } else if (step.status === "failed") { - failedSteps.push(step.stepKey); - } - } - return { - currentPhase: resolveCurrentPhase(graph), - completedSteps, - totalSteps: relevantSteps.length, - activeWorkers, - blockedSteps, - failedSteps, - }; -} - -function resolveDependencyStepKeys(graph: OrchestratorRunGraph, step: OrchestratorStep): string[] { - return step.dependencyStepIds - .map((depId) => graph.steps.find((candidate) => candidate.id === depId)?.stepKey ?? "") - .filter((depKey): depKey is string => depKey.length > 0); -} - -function dedupeKeys(keys: readonly unknown[] | null | undefined): string[] { - if (!Array.isArray(keys)) return []; - return [ - ...new Set( - keys - .map((key) => String(key ?? "").trim()) - .filter((key) => key.length > 0), - ), - ]; -} - -function normalizeText(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -type WorkerPhaseHint = "planning" | "implementation" | "testing" | "validation" | "integration"; - -function normalizePhaseHintText(value: unknown): string { - return String(value ?? "") - .toLowerCase() - .replace(/[_-]+/g, " ") - .replace(/[^a-z0-9:]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function inferWorkerPhaseHintFromRequest(args: { - name: string; - roleName?: string | null; - prompt: string; -}): WorkerPhaseHint | null { - const name = normalizePhaseHintText(args.name); - const role = normalizePhaseHintText(args.roleName); - const prompt = normalizePhaseHintText(args.prompt); - const identity = `${name} ${role}`.trim(); - - if ( - /\b(planning worker|planner|researcher)\b/.test(identity) - || /\bactive phase: planning\b/.test(prompt) - || /\byou are (the )?(read only |read only )?planning worker\b/.test(prompt) - ) { - return "planning"; - } - if ( - /\b(validator|validation worker|validation gate|validate result)\b/.test(identity) - || /\bactive phase: validation\b/.test(prompt) - || /\breport validation\b/.test(prompt) - ) { - return "validation"; - } - if ( - /\b(testing worker|test worker|test runner|vitest runner)\b/.test(identity) - || /\brun\b.*\btests?\b/.test(identity) - || /\btargeted\b.*\btests?\b/.test(identity) - || /\bactive phase: testing\b/.test(prompt) - || /\byou are (the )?testing worker\b/.test(prompt) - || /\brun tests collect failures\b/.test(prompt) - ) { - return "testing"; - } - if ( - /\b(integration worker|integration gate|result lane gate|merge worker)\b/.test(identity) - || /\bactive phase: integration\b/.test(prompt) - || /\bconsolidate\b.*\bresult lane\b/.test(prompt) - || /\bassemble\b.*\bresult lane\b/.test(prompt) - ) { - return "integration"; - } - if ( - /\b(development worker|implementation worker|implementer|builder)\b/.test(identity) - || /\bactive phase: development\b/.test(prompt) - || /\bactive phase: implementation\b/.test(prompt) - ) { - return "implementation"; - } - return null; -} - -function inferWorkerNameFromPrompt(prompt: string): string { - const lower = prompt.toLowerCase(); - const hasWorkerIdentityBoilerplate = prompt - .split(/\r?\n/) - .some((line) => /^you are (the )?(read-only |read only )?.*worker\b/.test(line.trim().toLowerCase())); - const phaseName: string | null = (() => { - if (lower.includes("read-only planning worker") || lower.includes("planning worker") || lower.includes("plan.markdown")) { - return "Planning worker"; - } - if (lower.includes("development worker") || lower.includes("implementation worker")) return "Development worker"; - if (lower.includes("integration worker")) return "Integration worker"; - if (lower.includes("testing worker") || lower.includes("test worker")) return "Testing worker"; - if (lower.includes("validation worker") || lower.includes("validator worker")) return "Validation worker"; - if (lower.includes("closeout worker")) return "Closeout worker"; - return null; - })(); - if (phaseName && hasWorkerIdentityBoilerplate) return phaseName; - const cleaned = prompt - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => { - if (!line.length) return false; - const normalized = line.toLowerCase(); - if (normalized.startsWith("mission goal:")) return false; - if (/^you are (the )?(read-only |read only )?.*worker\b/.test(normalized)) return false; - if (normalized.startsWith("active phase:")) return false; - if (normalized.startsWith("execution protocol:")) return false; - return true; - }) - ?.toLowerCase() - .replace(/[`"'“”‘’]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); - if (cleaned && cleaned.length >= 3) return cleaned; - return phaseName ?? "Mission worker"; -} - -function normalizeValidationVerdict(value: unknown): "pass" | "fail" | null { - const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (raw === "pass" || raw === "passed" || raw === "success" || raw === "succeeded") return "pass"; - if (raw === "fail" || raw === "failed" || raw === "failure") return "fail"; - return null; -} - -function normalizeDependencyReferences(args: { - graph: OrchestratorRunGraph; - refs: readonly unknown[] | null | undefined; - label: string; - additionalKnownKeys?: ReadonlySet<string>; -}): string[] { - const normalizedRefs = dedupeKeys(args.refs); - const normalizedKeys: string[] = []; - const unknownRefs: string[] = []; - for (const ref of normalizedRefs) { - const resolvedStep = resolveStep(args.graph, ref); - if (resolvedStep) { - normalizedKeys.push(resolvedStep.stepKey); - continue; - } - if (args.additionalKnownKeys?.has(ref)) { - normalizedKeys.push(ref); - continue; - } - unknownRefs.push(ref); - } - if (unknownRefs.length > 0) { - throw new Error(`${args.label} references unknown dependency keys: ${unknownRefs.join(", ")}`); - } - return dedupeKeys(normalizedKeys); -} - -function isTaskShellStep(step: OrchestratorStep | null | undefined): boolean { - const metadata = asRecord(step?.metadata) ?? {}; - if (metadata.plannerLaunchTracker === true || metadata.systemManaged === true) return false; - return isDisplayOnlyTaskStep(step); -} - -function normalizeTaskMatchText(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -function taskMatchTokens(value: string): Set<string> { - return new Set( - normalizeTaskMatchText(value) - .split(/\s+/) - .map((token) => token.trim()) - .filter((token) => token.length >= 3), - ); -} - -function taskShellMatchScore(step: OrchestratorStep, requestText: string): number { - const metadata = asRecord(step.metadata) ?? {}; - const shellText = [ - step.stepKey, - step.title, - typeof metadata.instructions === "string" ? metadata.instructions : "", - ].join(" "); - const normalizedShellKey = normalizeTaskMatchText(step.stepKey); - const normalizedShellTitle = normalizeTaskMatchText(step.title); - const normalizedRequest = normalizeTaskMatchText(requestText); - if (normalizedShellKey.length > 0 && normalizedRequest.includes(normalizedShellKey)) return 1; - if (normalizedShellTitle.length > 0 && normalizedRequest.includes(normalizedShellTitle)) return 1; - const shellTokens = taskMatchTokens(shellText); - const requestTokens = taskMatchTokens(requestText); - if (shellTokens.size === 0 || requestTokens.size === 0) return 0; - let overlap = 0; - for (const token of shellTokens) { - if (requestTokens.has(token)) overlap += 1; - } - return overlap / Math.min(shellTokens.size, requestTokens.size); -} - -function taskShellAssignedTo(step: OrchestratorStep): string | null { - const metadata = asRecord(step.metadata) ?? {}; - const assignedTo = typeof metadata.assignedTo === "string" ? metadata.assignedTo.trim() : ""; - return assignedTo.length > 0 ? assignedTo : null; -} - -function resolveExecutableDependencyKeys(graph: OrchestratorRunGraph, dependencyKeys: string[]): string[] { - const visited = new Set<string>(); - const resolved: string[] = []; - - const visit = (stepKey: string) => { - const normalizedKey = stepKey.trim(); - if (!normalizedKey || visited.has(normalizedKey)) return; - visited.add(normalizedKey); - - const step = resolveStep(graph, normalizedKey); - if (!step) { - resolved.push(normalizedKey); - return; - } - if (!isTaskShellStep(step)) { - resolved.push(step.stepKey); - return; - } - - const meta = asRecord(step.metadata) ?? {}; - const assignedTo = typeof meta.assignedTo === "string" ? meta.assignedTo.trim() : ""; - if (assignedTo.length > 0) { - visit(assignedTo); - return; - } - - for (const dependencyKey of resolveDependencyStepKeys(graph, step)) { - visit(dependencyKey); - } - }; - - for (const dependencyKey of dependencyKeys) { - visit(dependencyKey); - } - - return dedupeKeys(resolved); -} - -function resolveTeamRuntimeConfig(graph: OrchestratorRunGraph): TeamRuntimeConfig | null { - const runMeta = asRecord(graph.run.metadata); - const teamRuntime = asRecord(runMeta?.teamRuntime); - if (!teamRuntime || teamRuntime.enabled !== true) return null; - return { - enabled: true, - targetProvider: - teamRuntime.targetProvider === "claude" || - teamRuntime.targetProvider === "codex" || - teamRuntime.targetProvider === "auto" - ? teamRuntime.targetProvider - : "auto", - teammateCount: Number.isFinite(Number(teamRuntime.teammateCount)) - ? Math.max(0, Math.min(20, Math.floor(Number(teamRuntime.teammateCount)))) - : 2, - ...normalizeAgentRuntimeFlags(teamRuntime), - template: teamRuntime.template as TeamRuntimeConfig["template"], - toolProfiles: teamRuntime.toolProfiles as TeamRuntimeConfig["toolProfiles"], - policyOverrides: teamRuntime.policyOverrides as TeamRuntimeConfig["policyOverrides"] - }; -} - -function resolveRoleDefinition(teamRuntime: TeamRuntimeConfig | null, roleName: string): RoleDefinition | null { - if (!teamRuntime?.template?.roles?.length) return null; - const normalized = roleName.trim().toLowerCase(); - if (!normalized.length) return null; - const byName = teamRuntime.template.roles.find((role) => role.name.trim().toLowerCase() === normalized) ?? null; - if (byName) return byName; - return ( - teamRuntime.template.roles.find((role) => - role.capabilities.some((capability) => capability.trim().toLowerCase() === normalized) - ) ?? null - ); -} - -function resolveRoleToolProfile(teamRuntime: TeamRuntimeConfig | null, roleName: string): Record<string, unknown> | null { - const normalized = roleName.trim().toLowerCase(); - if (!normalized.length) return null; - if (teamRuntime?.toolProfiles) { - for (const [key, profile] of Object.entries(teamRuntime.toolProfiles)) { - if (key.trim().toLowerCase() === normalized) { - return profile as unknown as Record<string, unknown>; - } - } - } - const roleDef = resolveRoleDefinition(teamRuntime, normalized); - return (roleDef?.toolProfile as unknown as Record<string, unknown>) ?? null; -} - -function parseValidationContract(value: unknown): ValidationContract | null { - const raw = asRecord(value); - if (!raw) return null; - if ( - raw.level !== "step" && - raw.level !== "milestone" && - raw.level !== "mission" - ) { - return null; - } - if ( - raw.tier !== "self" && - raw.tier !== "dedicated" - ) { - return null; - } - const criteria = typeof raw.criteria === "string" ? raw.criteria.trim() : ""; - if (!criteria.length) return null; - return { - level: raw.level, - tier: raw.tier, - required: raw.required !== false, - criteria, - evidence: Array.isArray(raw.evidence) - ? raw.evidence.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : [], - maxRetries: Number.isFinite(Number(raw.maxRetries)) - ? Math.max(0, Math.min(10, Math.floor(Number(raw.maxRetries)))) - : 1 - }; -} - -function resolveValidationStateFromStepMetadata(metadata: Record<string, unknown>): "pass" | "fail" | "pending" { - const normalizedState = normalizeValidationVerdict(metadata.validationState); - if (normalizedState) return normalizedState; - const lastValidationReport = asRecord(metadata.lastValidationReport); - const reportVerdict = normalizeValidationVerdict(lastValidationReport?.verdict); - if (reportVerdict) return reportVerdict; - const validationPassedAt = typeof metadata.validationPassedAt === "string" ? metadata.validationPassedAt.trim() : ""; - if (validationPassedAt.length > 0) return "pass"; - return "pending"; -} - -function isAutoSpawnedValidationProofStep(step: OrchestratorStep | null | undefined): boolean { - const metadata = asRecord(step?.metadata) ?? {}; - return metadata.autoSpawnedValidation === true; -} - -function buildStalenessSignals(graph: OrchestratorRunGraph): string[] { - const signals: string[] = []; - const nowMs = Date.now(); - const relevantSteps = filterExecutionSteps(graph.steps); - const repeatedFailures = relevantSteps.filter((step) => step.status === "failed" && step.retryCount >= step.retryLimit && step.retryLimit > 0); - if (repeatedFailures.length > 0) { - signals.push(`${repeatedFailures.length} step(s) exhausted retries`); - } - const runningAttempts = graph.attempts.filter((attempt) => attempt.status === "running"); - const staleRunning = runningAttempts.filter((attempt) => nowMs - Date.parse(attempt.createdAt) > 10 * 60_000); - if (staleRunning.length > 0) { - signals.push(`${staleRunning.length} running attempt(s) older than 10 minutes`); - } - const blockedSteps = relevantSteps.filter((step) => step.status === "blocked"); - if (blockedSteps.length > 0) { - signals.push(`${blockedSteps.length} blocked step(s)`); - } - const validationEscalations = relevantSteps.filter( - (step) => asRecord(step.metadata)?.validationEscalationRequired === true - ); - if (validationEscalations.length > 0) { - signals.push(`${validationEscalations.length} validation gate(s) exceeded retry budget`); - } - return signals; -} - -function findLatestCompletedAttempt(graph: OrchestratorRunGraph, stepId: string): OrchestratorAttempt | null { - const attempts = graph.attempts - .filter((attempt) => attempt.stepId === stepId && attempt.status !== "running") - .sort((a, b) => (b.completedAt ?? b.createdAt).localeCompare(a.completedAt ?? a.createdAt)); - return attempts[0] ?? null; -} - -function parseValidationFinding(value: unknown): ValidationResultReport["findings"][number] | null { - const raw = asRecord(value); - if (!raw) return null; - const code = typeof raw.code === "string" ? raw.code.trim() : ""; - const severity = - raw.severity === "low" || raw.severity === "medium" || raw.severity === "high" - ? raw.severity - : "medium"; - const message = typeof raw.message === "string" ? raw.message.trim() : ""; - if (!code || !message) return null; - const references = Array.isArray(raw.references) - ? raw.references - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : undefined; - return { - code, - severity, - message, - remediation: typeof raw.remediation === "string" && raw.remediation.trim().length > 0 ? raw.remediation.trim() : undefined, - ...(references && references.length > 0 ? { references } : {}) - }; -} - -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - -export function createCoordinatorToolSet(deps: { - orchestratorService: ReturnType<typeof createOrchestratorService>; - missionService: ReturnType<typeof createMissionService>; - getMissionBudgetStatus?: () => Promise<MissionBudgetSnapshot | null>; - runId: string; - missionId: string; - projectId?: string; - logger: Logger; - db: AdeDb; - projectRoot: string; - workspaceRoot: string; - onDagMutation: (event: DagMutationEvent) => void; - onRunFinalize?: (args: { runId: string; succeeded: boolean; summary?: string; reason?: string }) => void; - onHardCapTriggered?: (detail: string) => void; - onBudgetWarning?: (pressure: "warning" | "critical", detail: string) => void; - sendWorkerMessageToSession?: CoordinatorSendWorkerMessageFn; - /** Primary mission lane ID — used by provision_lane to branch new lanes. */ - missionLaneId?: string; - /** Callback to create a new lane branching from the mission's base lane. */ - provisionLane?: (name: string, description?: string) => Promise<{ laneId: string; name: string }>; -}): Record<string, CoordinatorExecutableTool> { - const { - orchestratorService, - missionService, - getMissionBudgetStatus, - runId, - missionId, - logger, - db, - projectRoot, - workspaceRoot, - onDagMutation - } = deps; - const projectId = typeof deps.projectId === "string" && deps.projectId.trim().length > 0 - ? deps.projectId.trim() - : null; - const missionLaneId = typeof deps.missionLaneId === "string" && deps.missionLaneId.trim().length > 0 - ? deps.missionLaneId.trim() - : null; - const resolvedWorkspaceRoot = path.resolve(workspaceRoot); - const resolvedWorkspaceRootReal = fs.existsSync(resolvedWorkspaceRoot) - ? fs.realpathSync(resolvedWorkspaceRoot) - : resolvedWorkspaceRoot; - const resolveWorkspacePath = (candidatePath: string, allowMissing: boolean = false): string | null => { - try { - return resolvePathWithinRoot(resolvedWorkspaceRoot, candidatePath, { allowMissing }); - } catch { - return null; - } - }; - const getFsErrorCode = (error: unknown): string | null => { - if (!error || typeof error !== "object" || !("code" in error)) return null; - const code = (error as { code?: unknown }).code; - return typeof code === "string" ? code : null; - }; - const formatWorkspaceReadError = (kind: "file" | "step output", reference: string, error: unknown): string => { - const code = getFsErrorCode(error); - if (code === "ENOENT" || code === "ENOTDIR") { - return `file not found: ${reference}`; - } - if (code === "EACCES" || code === "EPERM") { - return `permission denied: ${reference}`; - } - return `failed to read ${kind}: ${reference}`; - }; - const MAX_CONTENT_SEARCH_PATTERN_LENGTH = 200; - const filenamePatternToRegExp = (pattern: string): RegExp => { - const normalized = pattern.replace(/\\/g, "/").trim(); - if (!normalized.length) return /^$/i; - let source = "^"; - for (let index = 0; index < normalized.length; index += 1) { - const char = normalized[index]!; - if (char === "*") { - const next = normalized[index + 1]; - if (next === "*") { - if (normalized[index + 2] === "/") { - source += "(?:.*/)?"; - index += 2; - } else { - source += ".*"; - index += 1; - } - } else { - source += "[^/]*"; - } - continue; - } - source += escapeRegExp(char); - } - source += "$"; - return new RegExp(source, "i"); - }; - const isUnsafeContentSearchPattern = (pattern: string): boolean => { - if (pattern.length > MAX_CONTENT_SEARCH_PATTERN_LENGTH) return true; - return /\\[1-9]/.test(pattern) - || /\(\?[:!=<]/.test(pattern) - || /\((?:[^()\\]|\\.)*[+*{](?:[^()\\]|\\.)*\)[+*{]/.test(pattern) - || /(?:^|[^\\])(?:\*|\+|\{[^}]+\})(?:\s*)(?:\*|\+|\{[^}]+\})/.test(pattern); - }; - const compileContentSearchRegex = (pattern: string, explicitRegexMode: boolean = false): RegExp => { - const normalized = pattern.trim(); - if (!normalized.length) { - throw new Error("pattern is required"); - } - if (!explicitRegexMode) { - return new RegExp(escapeRegExp(normalized), "i"); - } - try { - if (isUnsafeContentSearchPattern(normalized)) { - throw new Error("Unsafe regular expression pattern."); - } - return new RegExp(normalized, "i"); - } catch (error) { - if (error instanceof Error && error.message === "Unsafe regular expression pattern.") { - throw error; - } - throw new Error(`Invalid regular expression pattern: ${normalized}`); - } - }; - - const normalizeLaneId = (value: string | null | undefined): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }; - - /** Track last emitted budget pressure to avoid spamming soft warnings. */ - let lastEmittedBudgetPressure: "normal" | "warning" | "critical" = "normal"; - const runningWorkerOutputChecks = new Map<string, { checkedAtMs: number; signalFingerprint: string }>(); - - /** Shorthand to get a fresh graph snapshot. */ - function graph(): OrchestratorRunGraph { - return orchestratorService.getRunGraph({ runId }); - } - - /** Register a spawned worker/sub-agent as a team member for tracking. */ - function trackTeamMember(args: { - workerId: string; - provider: string; - modelId: string; - role: string | null; - source: "ade-worker" | "ade-subagent"; - isSubAgent?: boolean; - parentWorkerId?: string | null; - }): void { - try { - const now = nowIso(); - registerTeamMember( - { db, logger } as import("./orchestratorContext").OrchestratorContext, - { - id: args.workerId, - runId, - missionId, - provider: args.provider, - model: args.modelId, - role: args.isSubAgent ? "teammate" : "worker", - source: args.source, - parentWorkerId: args.parentWorkerId ?? null, - sessionId: null, - status: "spawning", - claimedTaskIds: [], - metadata: { - source: args.source, - ...(args.role ? { teamRole: args.role } : {}), - ...(args.isSubAgent ? { isSubAgent: true } : {}), - ...(args.parentWorkerId ? { parentWorkerId: args.parentWorkerId } : {}), - }, - createdAt: now, - updatedAt: now, - }, - ); - } catch (err) { - // Non-critical — team member tracking is best-effort - logger.debug("coordinator.track_team_member_failed", { - workerId: args.workerId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - function resolveMissionGoal(): string { - const mission = missionService.get(missionId); - const prompt = typeof mission?.prompt === "string" ? mission.prompt.trim() : ""; - if (prompt.length > 0) return prompt; - const title = typeof mission?.title === "string" ? mission.title.trim() : ""; - if (title.length > 0) return title; - return `Mission ${missionId}`; - } - - async function deliverToWorkerSession( - sessionId: string, - text: string, - priority: "normal" | "urgent" = "normal", - ): Promise<CoordinatorWorkerDeliveryStatus> { - if (!deps.sendWorkerMessageToSession) { - return { - ok: false, - delivered: false, - reason: "delivery_failed", - error: "Worker delivery transport is unavailable." - }; - } - try { - return await deps.sendWorkerMessageToSession({ sessionId, text, priority }); - } catch (error) { - return { - ok: false, - delivered: false, - reason: "delivery_failed", - error: error instanceof Error ? error.message : String(error) - }; - } - } - - /** - * Shared budget hard cap check — refuses worker spawning when budget limits are triggered. - * Returns `{ blocked: false }` if spawning is allowed, or `{ blocked: true, detail, hardCaps }` if blocked. - */ - async function checkBudgetHardCaps(options?: { - failClosedOnTelemetryError?: boolean; - operation?: "spawn_worker" | "request_specialist" | "revise_plan" | "delegate_to_subagent" | "delegate_parallel"; - }): Promise<{ - blocked: boolean; - detail?: string; - hardCaps?: MissionBudgetHardCapStatus; - }> { - if (!getMissionBudgetStatus) return { blocked: false }; - try { - const budgetSnap = await getMissionBudgetStatus(); - if (!budgetSnap?.hardCaps) return { blocked: false }; - const caps = budgetSnap.hardCaps; - const anyTriggered = caps.fiveHourTriggered || caps.weeklyTriggered || caps.apiKeyTriggered; - if (!anyTriggered) return { blocked: false }; - const reasons: string[] = []; - if (caps.fiveHourTriggered) { - const prov = budgetSnap.perProvider.find((p) => { - const pct = p.fiveHour.usedPct ?? 0; - return pct >= (caps.fiveHourHardStopPercent ?? 100); - }); - const provName = prov?.provider ?? "aggregate"; - const usedPct = prov?.fiveHour.usedPct ?? 0; - reasons.push(`${provName} 5hr usage at ${Math.round(usedPct)}% (hard cap: ${caps.fiveHourHardStopPercent ?? 100}%)`); - } - if (caps.weeklyTriggered) { - const prov = budgetSnap.perProvider.find((p) => { - const pct = p.weekly.usedPct ?? 0; - return pct >= (caps.weeklyHardStopPercent ?? 100); - }); - const provName = prov?.provider ?? "aggregate"; - const usedPct = prov?.weekly.usedPct ?? 0; - reasons.push(`${provName} weekly usage at ${Math.round(usedPct)}% (hard cap: ${caps.weeklyHardStopPercent ?? 100}%)`); - } - if (caps.apiKeyTriggered) { - reasons.push(`API key spend $${caps.apiKeySpentUsd.toFixed(2)} (hard cap: $${caps.apiKeyMaxSpendUsd?.toFixed(2) ?? "?"} )`); - } - const detail = reasons.join("; "); - if (deps.onHardCapTriggered) { - deps.onHardCapTriggered(detail); - } - return { blocked: true, detail, hardCaps: caps }; - } catch (budgetErr) { - const errorMessage = budgetErr instanceof Error ? budgetErr.message : String(budgetErr); - logger.debug("coordinator.budget_hard_cap_check_failed", { - error: errorMessage, - failClosedOnTelemetryError: options?.failClosedOnTelemetryError === true, - operation: options?.operation ?? null, - }); - if (options?.failClosedOnTelemetryError) { - const operation = options.operation ?? "high_cost_operation"; - const detail = `Budget telemetry unavailable while evaluating ${operation}: ${errorMessage}`; - if (deps.onHardCapTriggered) { - deps.onHardCapTriggered(detail); - } - return { blocked: true, detail }; - } - return { blocked: false }; - } - } - - type PhaseModelResolution = - | { - ok: true; - modelId: string; - } - | { - ok: false; - error: string; - }; - - function resolveModelFromPhaseModel(g: OrchestratorRunGraph): PhaseModelResolution { - const phaseContext = resolveConfiguredPhaseContext(g); - if (!phaseContext.ok) { - return { - ok: false, - error: phaseContext.error, - }; - } - const runMeta = asRecord(g.run.metadata); - const phaseRuntime = asRecord(runMeta?.phaseRuntime); - const currentPhaseModel = asRecord(phaseRuntime?.currentPhaseModel); - const modelIdHint = typeof currentPhaseModel?.modelId === "string" ? currentPhaseModel.modelId.trim() : ""; - if (modelIdHint.length > 0) { - const descriptor = resolveModelDescriptor(modelIdHint); - if (!descriptor) { - return { - ok: false, - error: `Current phase model '${modelIdHint}' is not registered. Select a valid model ID before spawning workers.` - }; - } - return { ok: true, modelId: descriptor.id }; - } - - return { ok: false, error: "Current phase does not define modelId. Configure a phase model before spawning workers." }; - } - - type WorkerSpawnPolicyResult = - | { - ok: true; - missionPhases: PhaseCard[]; - currentPhase: PhaseCard | null; - resolvedModelId: string; - resolvedProvider: string; - } - | { - ok: false; - error: string; - blockedByPhaseOrdering?: boolean; - blockedByValidationGate?: boolean; - phaseTransitionRequiredKey?: string | null; - phaseTransitionRequiredReason?: string | null; - }; - - function phaseTransitionRequiredPayload(phaseKey: string | null | undefined, reason = "planning_complete"): Record<string, unknown> { - const normalizedPhaseKey = typeof phaseKey === "string" ? phaseKey.trim() : ""; - if (!normalizedPhaseKey.length) return {}; - return { - phaseTransitionRequired: { - phaseKey: normalizedPhaseKey, - reason, - }, - requiredTool: { - name: "set_current_phase", - args: { - phaseKey: normalizedPhaseKey, - reason, - }, - }, - }; - } - - function phaseOrderingBlockedResult(error: string, phaseKey?: string | null, reason?: string | null) { - return { - ok: false, - error, - blockedByPhaseOrdering: true, - ...phaseTransitionRequiredPayload(phaseKey, reason ?? "planning_complete"), - }; - } - - function authorizeWorkerSpawnPolicy(args: { - g: OrchestratorRunGraph; - requestedModelId?: string | null; - }): WorkerSpawnPolicyResult { - const missionPhases = resolveMissionPhases(args.g); - const phaseContext = resolveConfiguredPhaseContext(args.g, missionPhases); - if (!phaseContext.ok) { - return { ok: false, error: phaseContext.error }; - } - if (missionPhases.length > 0) { - const phaseCheck = validatePhaseOrdering(missionPhases, args.g); - if (!phaseCheck.valid) { - return { - ok: false, - error: phaseCheck.reason, - blockedByPhaseOrdering: true, - phaseTransitionRequiredKey: phaseCheck.phaseTransitionRequiredKey ?? null, - phaseTransitionRequiredReason: phaseCheck.phaseTransitionRequiredReason ?? null, - }; - } - const validationGateCheck = validateRequiredValidationGates(missionPhases, args.g); - if (!validationGateCheck.valid) { - return { - ok: false, - error: validationGateCheck.reason, - blockedByValidationGate: true, - }; - } - } - - const explicitModelId = typeof args.requestedModelId === "string" ? args.requestedModelId.trim() : ""; - let resolvedModelId = explicitModelId; - if (!resolvedModelId.length) { - const phaseModelResolution = resolveModelFromPhaseModel(args.g); - if (!phaseModelResolution.ok) { - return { ok: false, error: phaseModelResolution.error }; - } - resolvedModelId = phaseModelResolution.modelId; - } - const resolvedDescriptor = resolveModelDescriptor(resolvedModelId); - if (!resolvedDescriptor) { - return { ok: false, error: `Model '${resolvedModelId}' is not registered.` }; - } - - const currentPhaseModelId = - missionPhases.length > 0 && typeof phaseContext.currentPhase?.model?.modelId === "string" - ? phaseContext.currentPhase.model.modelId.trim() - : ""; - if (explicitModelId.length > 0 && currentPhaseModelId.length > 0) { - const currentPhaseDescriptor = resolveModelDescriptor(currentPhaseModelId); - const normalizedPhaseModelId = currentPhaseDescriptor?.id ?? currentPhaseModelId; - if (resolvedDescriptor.id !== normalizedPhaseModelId) { - const phaseLabel = phaseContext.currentPhase?.name; - return { - ok: false, - error: - `${phaseLabel ? `Current phase "${phaseLabel}"` : "Current phase"} is configured for model "${normalizedPhaseModelId}". ` + - `Omit modelId to use the phase model, or call set_current_phase before switching models.` - }; - } - } - - const resolvedProvider = resolveProviderGroupForModel(resolvedDescriptor); - - return { - ok: true, - missionPhases, - currentPhase: phaseContext.currentPhase, - resolvedModelId: resolvedDescriptor.id, - resolvedProvider, - }; - } - - // ─── Worker Management ──────────────────────────────────────── - - const spawnWorkerStep = (args: { - stepKey?: string | null; - taskKey?: string | null; - name: string; - modelId: string; - prompt: string; - dependsOn?: string[] | null; - requestedDependsOn?: string[] | null; - inferredDependsOn?: string[] | null; - roleName?: string | null; - laneId?: string | null; - validationContract?: ValidationContract | null; - specialistRequest?: { requestedBy?: string | null; reason?: string | null } | null; - replacementForWorkerId?: string | null; - replacementReason?: string | null; - delegationContract?: DelegationContract | null; - phaseOverride?: PhaseCard | null; - phaseHint?: WorkerPhaseHint | null; - readOnly?: boolean | null; - }): { - workerId: string; - step: OrchestratorStep | null; - roleName: string | null; - modelId: string; - toolProfile: Record<string, unknown> | null; - reusedExistingStep: boolean; - } => { - const g = graph(); - const requestedDependencyStepKeys = normalizeDependencyReferences({ - graph: g, - refs: args.requestedDependsOn ?? args.dependsOn, - label: `Worker '${args.name}'`, - }); - const inferredDependencyStepKeys = normalizeDependencyReferences({ - graph: g, - refs: args.inferredDependsOn, - label: `Worker '${args.name}'`, - }); - const resolvedDependencyStepKeys = normalizeDependencyReferences({ - graph: g, - refs: args.dependsOn, - label: `Worker '${args.name}'`, - }); - const executableDependencyStepKeys = resolveExecutableDependencyKeys(g, resolvedDependencyStepKeys); - const taskShellDependencyKeys = requestedDependencyStepKeys.filter((dependencyKey) => { - const dependencyStep = resolveStep(g, dependencyKey); - return Boolean(dependencyStep && isTaskShellStep(dependencyStep)); - }); - const teamRuntime = resolveTeamRuntimeConfig(g); - const roleDef = args.roleName ? resolveRoleDefinition(teamRuntime, args.roleName) : null; - const roleName = roleDef?.name ?? (args.roleName?.trim().length ? args.roleName.trim() : null); - const toolProfile = roleName ? resolveRoleToolProfile(teamRuntime, roleName) : null; - const resolvedModelId = args.modelId.trim(); - if (!resolvedModelId.length) { - throw new Error("spawnWorkerStep requires a non-empty modelId."); - } - const resolvedDescriptor = resolveModelDescriptor(resolvedModelId); - const resolvedProvider = resolvedDescriptor - ? resolveProviderGroupForModel(resolvedDescriptor) - : "unknown"; - const replacementForWorkerId = args.replacementForWorkerId?.trim() || null; - const replacementSourceStep = replacementForWorkerId ? resolveStep(g, replacementForWorkerId) : null; - if (replacementForWorkerId && !replacementSourceStep) { - throw new Error(`Replacement source worker not found: ${replacementForWorkerId}`); - } - const replacementSourceMeta = asRecord(replacementSourceStep?.metadata) ?? {}; - const replacementStatusReport = asRecord(replacementSourceMeta.lastStatusReport) ?? {}; - const replacementResultReport = asRecord(replacementSourceMeta.lastResultReport) ?? {}; - const replacementValidationReport = asRecord(replacementSourceMeta.lastValidationReport); - const replacementAttempt = replacementSourceStep ? findLatestCompletedAttempt(g, replacementSourceStep.id) : null; - const inheritedLaneId = normalizeLaneId(replacementSourceStep?.laneId ?? null); - const replacementReason = args.replacementReason?.trim() || "Replacement worker requested by coordinator."; - const replacementSourceSummary = - (typeof replacementResultReport.summary === "string" && replacementResultReport.summary.trim().length > 0 - ? replacementResultReport.summary.trim() - : replacementAttempt?.resultEnvelope?.summary) ?? null; - const replacementChangedFiles = Array.isArray(replacementResultReport.filesChanged) - ? replacementResultReport.filesChanged - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : []; - const replacementFailedChecks = - replacementResultReport.testsRun && - typeof replacementResultReport.testsRun === "object" && - Number((replacementResultReport.testsRun as Record<string, unknown>).failed ?? 0) > 0 - ? [ - { - failed: Number((replacementResultReport.testsRun as Record<string, unknown>).failed ?? 0), - raw: - typeof (replacementResultReport.testsRun as Record<string, unknown>).raw === "string" - ? (replacementResultReport.testsRun as Record<string, unknown>).raw - : null - } - ] - : []; - const explicitLaneId = normalizeLaneId(args.laneId ?? null); - const effectiveLaneId = explicitLaneId ?? inheritedLaneId ?? missionLaneId; - const maxIndex = g.steps.reduce( - (max, s) => Math.max(max, s.stepIndex), - -1, - ); - const explicitTaskKey = args.taskKey?.trim() ?? ""; - const explicitStepKey = explicitTaskKey || args.stepKey?.trim() || ""; - const stepKey = - explicitStepKey.length > 0 - ? explicitStepKey - : `worker_${args.name.replace(/[^a-zA-Z0-9_-]/g, "_")}_${Date.now()}`; - const missionPhases = resolveMissionPhases(); - const phaseMetadata = buildPhaseMetadataForWorkerRequest({ - graph: g, - phases: missionPhases, - phaseOverride: args.phaseOverride ?? null, - phaseHint: args.phaseHint ?? null, - policyPrompt: args.prompt, - }); - const workerInstructions = appendPlanningResultContract(args.prompt, phaseMetadata); - const policyPhaseMetadata = applyPlanningQuestionPolicyToWorkerMetadata({ - metadata: phaseMetadata, - phases: missionPhases, - policyPrompt: workerInstructions, - }); - const effectivePhaseMetadata = { - ...policyPhaseMetadata, - ...(args.readOnly === true ? { readOnlyExecution: true } : {}), - }; - const resolveReusableTaskShell = (): OrchestratorStep | null => { - const exactCandidate = resolveStep(g, stepKey); - if (exactCandidate && isTaskShellStep(exactCandidate) && !TERMINAL_STEP_STATUSES.has(exactCandidate.status)) { - if (explicitTaskKey.length > 0 || taskShellMatchScore(exactCandidate, `${args.name} ${args.prompt}`) >= 0.18) { - return exactCandidate; - } - } - - const currentPhase = args.phaseOverride ?? (missionPhases.length > 0 ? resolveCurrentPhaseCard(g, missionPhases) : null); - const requestText = `${args.name} ${args.prompt}`; - const candidates = g.steps - .filter((candidate) => isTaskShellStep(candidate)) - .filter((candidate) => !TERMINAL_STEP_STATUSES.has(candidate.status)) - .filter((candidate) => !taskShellAssignedTo(candidate)) - .filter((candidate) => { - if (!currentPhase) return true; - return stepBelongsToPhase(candidate, currentPhase); - }) - .map((candidate) => ({ candidate, score: taskShellMatchScore(candidate, requestText) })) - .filter((entry) => entry.score >= 0.18) - .sort((a, b) => b.score - a.score); - if (candidates.length === 0) return null; - if (candidates.length === 1) return candidates[0]!.candidate; - return candidates[0]!.score > candidates[1]!.score ? candidates[0]!.candidate : null; - }; - const reusableTaskShell = - !replacementSourceStep - ? resolveReusableTaskShell() - : null; - if (reusableTaskShell) { - let preparedStep = orchestratorService.updateStepMetadata({ - runId, - stepId: reusableTaskShell.id, - retryLimit: args.delegationContract?.failurePolicy.retryLimit, - metadata: { - ...effectivePhaseMetadata, - executorKind: resolvedProvider, - instructions: workerInstructions, - workerName: args.name, - requestedDependencyStepKeys, - ...(inferredDependencyStepKeys.length > 0 ? { inferredDependencyStepKeys } : {}), - planningTaskDependencies: taskShellDependencyKeys, - spawnedByCoordinator: true, - modelId: resolvedModelId, - modelProviderHint: resolvedProvider, - modelExecutionPath: resolvedDescriptor ? classifyWorkerExecutionPath(resolvedDescriptor) : "api", - role: roleName, - roleCapabilities: roleDef?.capabilities ?? [], - toolProfile: toolProfile ?? null, - isTask: false, - displayOnlyTask: false, - convertedFromTaskShell: true, - ...(args.delegationContract ? { delegationContract: args.delegationContract } : {}), - ...(args.validationContract ? { validationContract: args.validationContract } : {}), - } - }); - if ((preparedStep.laneId ?? null) !== (effectiveLaneId ?? null)) { - preparedStep = orchestratorService.transferStepLane({ - runId, - stepId: preparedStep.id, - laneId: effectiveLaneId, - reason: "Converted task shell into an executable worker step.", - transferredBy: "coordinator", - }); - } - preparedStep = orchestratorService.updateStepDependencies({ - runId, - stepId: preparedStep.id, - dependencyStepKeys: executableDependencyStepKeys, - }); - return { - workerId: preparedStep.stepKey, - step: preparedStep, - roleName, - modelId: resolvedModelId, - toolProfile, - reusedExistingStep: true, - }; - } - const created = orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey, - title: args.name, - stepIndex: maxIndex + 1, - laneId: effectiveLaneId, - dependencyStepKeys: executableDependencyStepKeys, - executorKind: resolvedProvider, - retryLimit: args.delegationContract?.failurePolicy.retryLimit, - metadata: { - ...effectivePhaseMetadata, - instructions: workerInstructions, - workerName: args.name, - requestedDependencyStepKeys, - ...(inferredDependencyStepKeys.length > 0 ? { inferredDependencyStepKeys } : {}), - planningTaskDependencies: taskShellDependencyKeys, - spawnedByCoordinator: true, - modelId: resolvedModelId, - modelProviderHint: resolvedProvider, - modelExecutionPath: resolvedDescriptor ? classifyWorkerExecutionPath(resolvedDescriptor) : "api", - role: roleName, - roleCapabilities: roleDef?.capabilities ?? [], - toolProfile: toolProfile ?? null, - ...(args.delegationContract ? { delegationContract: args.delegationContract } : {}), - ...(args.validationContract ? { validationContract: args.validationContract } : {}), - ...(replacementSourceStep - ? { - replacementContext: { - replacedWorkerId: replacementSourceStep.stepKey, - replacedStepId: replacementSourceStep.id, - inheritedLaneId, - reason: replacementReason, - sourceSummary: replacementSourceSummary, - changedFiles: replacementChangedFiles, - failedChecks: replacementFailedChecks, - priorValidatorFeedback: replacementValidationReport ?? null - } - } - : {}), - ...(args.specialistRequest - ? { - specialistRequest: { - requestedBy: args.specialistRequest.requestedBy ?? null, - reason: args.specialistRequest.reason ?? null - } - } - : {}) - }, - }, - ], - }); - if (replacementSourceStep && created[0]) { - orchestratorService.createHandoff({ - missionId, - runId, - stepId: created[0].id, - handoffType: "worker_replacement_handoff", - producer: "coordinator", - payload: { - replacedWorkerId: replacementSourceStep.stepKey, - replacedStepId: replacementSourceStep.id, - replacementWorkerId: created[0].stepKey, - replacementStepId: created[0].id, - laneId: effectiveLaneId, - reason: replacementReason, - sourceSummary: replacementSourceSummary, - changedFiles: replacementChangedFiles, - failedChecks: replacementFailedChecks, - priorValidatorFeedback: replacementValidationReport ?? null, - priorStatusReport: replacementStatusReport ?? null - }, - }); - } - return { - workerId: stepKey, - step: created[0] ?? null, - roleName, - modelId: resolvedModelId, - toolProfile, - reusedExistingStep: false, - }; - }; - - // ─── Phase Ordering Helpers ───────────────────────────────────── - - /** - * Resolve mission phase cards from the mission's metadata in the DB. - * Returns an empty array when no phases are configured. - */ - function resolveMissionPhases(g?: OrchestratorRunGraph | null): PhaseCard[] { - try { - const runMeta = g ? asRecord(g.run.metadata) : null; - const runPhaseConfig = asRecord(runMeta?.phaseConfiguration); - if (runPhaseConfig) { - if (Array.isArray(runPhaseConfig.selectedPhases)) return runPhaseConfig.selectedPhases as PhaseCard[]; - if (Array.isArray(runPhaseConfig.phases)) return runPhaseConfig.phases as PhaseCard[]; - } - if (Array.isArray(runMeta?.phaseOverride)) return runMeta.phaseOverride as PhaseCard[]; - const runPhaseOverride = asRecord(runMeta?.phaseOverride); - if (runPhaseOverride) { - if (Array.isArray(runPhaseOverride.selectedPhases)) return runPhaseOverride.selectedPhases as PhaseCard[]; - if (Array.isArray(runPhaseOverride.phases)) return runPhaseOverride.phases as PhaseCard[]; - } - if (Array.isArray(runMeta?.phases)) return runMeta.phases as PhaseCard[]; - - const missionRow = db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [missionId], - ); - if (!missionRow?.metadata_json) return []; - const meta = JSON.parse(missionRow.metadata_json); - const raw = asRecord(meta); - if (!raw) return []; - const phaseConfig = asRecord(raw.phaseConfiguration); - if (phaseConfig) { - if (Array.isArray(phaseConfig.selectedPhases)) return phaseConfig.selectedPhases as PhaseCard[]; - if (Array.isArray(phaseConfig.phases)) return phaseConfig.phases as PhaseCard[]; - } - if (Array.isArray(raw.phaseOverride)) return raw.phaseOverride as PhaseCard[]; - if (Array.isArray(raw.phases)) return raw.phases as PhaseCard[]; - return []; - } catch { - return []; - } - } - - type PhaseContextResolution = - | { - ok: true; - phases: PhaseCard[]; - currentPhase: PhaseCard | null; - phaseRuntime: Record<string, unknown> | null; - } - | { - ok: false; - phases: PhaseCard[]; - error: string; - }; - - function resolveConfiguredPhaseContext( - g: OrchestratorRunGraph, - phasesInput?: PhaseCard[], - ): PhaseContextResolution { - const phases = [...(phasesInput ?? resolveMissionPhases(g))].sort((a, b) => a.position - b.position); - const runMeta = asRecord(g.run.metadata); - const phaseRuntime = asRecord(runMeta?.phaseRuntime); - if (phases.length === 0) { - return { - ok: true, - phases, - currentPhase: null, - phaseRuntime, - }; - } - if (!phaseRuntime) { - return { - ok: false, - phases, - error: - "Mission phases are configured, but phase runtime is missing. Call set_current_phase before spawning or delegating workers." - }; - } - const runtimePhaseKey = typeof phaseRuntime.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - const runtimePhaseName = typeof phaseRuntime.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - if (!runtimePhaseKey && !runtimePhaseName) { - return { - ok: false, - phases, - error: - "Mission phases are configured, but the current phase is unset. Call set_current_phase before spawning or delegating workers." - }; - } - const currentPhase = phases.find( - (phase) => phase.phaseKey === runtimePhaseKey || phase.name === runtimePhaseName, - ); - if (!currentPhase) { - const requestedLabel = runtimePhaseName || runtimePhaseKey; - return { - ok: false, - phases, - error: - `Current phase "${requestedLabel}" is not present in the configured mission phases. ` + - "Call set_current_phase with a valid phase before spawning or delegating workers." - }; - } - return { - ok: true, - phases, - currentPhase, - phaseRuntime, - }; - } - - function phaseAllowsValidationWorker(phase: PhaseCard | null | undefined): boolean { - if (!phase) return false; - const stepType = resolvePhaseStepType(phase.phaseKey); - if (stepType.includes("validation") || stepType.includes("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; - prompt: string; - validationContract: ValidationContract | null; - validationHeuristics?: "strict" | "prompt_only"; - }): { ok: true } | { ok: false; error: string } { - if (args.currentPhaseKey === "planning" && promptContainsImplementationDirectives(args.prompt)) { - return { - ok: false, - error: - 'Current phase is "planning". Planning workers must stay read-only and produce research/plan output only. ' + - `This prompt contains implementation or commit instructions. Use a research prompt now, then call set_current_phase with phaseKey "${args.nextImplementationPhaseKey}" before implementation work.` - }; - } - const validationHeuristics = args.validationHeuristics ?? "strict"; - if ( - !phaseAllowsValidationWorker(args.currentPhase) - && looksLikeValidationWorkerRequest({ - name: args.workerName, - roleName: validationHeuristics === "strict" ? args.roleName : null, - prompt: args.prompt, - validationContract: args.validationContract, - includeNameHeuristics: validationHeuristics === "strict", - includePromptHeuristics: args.currentPhaseKey !== "planning", - }) - ) { - return { - ok: false, - error: - '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 }; - } - - function buildDelegationFailurePolicy(args: { - mode: DelegationMode; - intent: DelegationIntent; - }): DelegationContract["failurePolicy"] { - if (args.intent === "planner") { - return { - retryLimit: 1, - escalation: "intervention", - }; - } - if (args.mode === "recovery") { - return { - retryLimit: 0, - escalation: "intervention", - }; - } - return { - retryLimit: 1, - escalation: "retry", - }; - } - - function buildCoordinatorDelegationContract(args: { - graph: OrchestratorRunGraph; - workerId: string; - phaseKey: string; - workerName: string; - intent: DelegationIntent; - mode: DelegationMode; - scopeKind: "phase" | "step" | "worker" | "batch"; - scopeKey: string; - scopeLabel?: string | null; - batchId?: string | null; - parentContractId?: string | null; - launchState?: DelegationContract["launchState"]; - metadata?: Record<string, unknown> | null; - }): DelegationContract { - const currentPhaseLabel = - args.phaseKey.trim().length > 0 - ? args.phaseKey.trim() - : resolveCurrentPhase(args.graph); - return createDelegationContract({ - contractId: randomUUID(), - runId, - workerIntent: args.intent, - mode: args.mode, - scope: createDelegationScope({ - kind: args.scopeKind, - key: args.scopeKey, - label: args.scopeLabel ?? currentPhaseLabel, - }), - phaseKey: currentPhaseLabel, - status: args.intent === "planner" && args.mode === "exclusive" ? "launching" : "active", - launchState: args.launchState ?? (args.intent === "planner" ? "awaiting_worker_launch" : "waiting_on_worker"), - activeWorkerIds: [args.workerId], - launchPolicy: { - maxLaunchAttempts: args.intent === "planner" ? 2 : 1, - }, - failurePolicy: buildDelegationFailurePolicy({ - mode: args.mode, - intent: args.intent, - }), - batchId: args.batchId ?? null, - parentContractId: args.parentContractId ?? null, - metadata: { - workerName: args.workerName, - ...(args.metadata ?? {}), - }, - }); - } - - function persistDelegationContract(args: { - contract: DelegationContract; - stepId: string | null; - reason: string; - action: "created" | "updated"; - }): void { - const detail = { - source: "coordinator_delegation", - action: args.action, - contract: args.contract, - }; - orchestratorService.appendRuntimeEvent({ - runId, - stepId: args.stepId, - eventType: "progress", - eventKey: `coordinator_delegation:${args.contract.contractId}:${args.action}:${args.contract.status}:${args.contract.updatedAt}`, - occurredAt: args.contract.updatedAt, - payload: detail, - }); - orchestratorService.appendTimelineEvent({ - runId, - stepId: args.stepId, - eventType: "delegation_contract", - reason: args.reason, - detail, - }); - orchestratorService.emitRuntimeUpdate({ - runId, - stepId: args.stepId, - reason: args.reason, - }); - } - - function applyDelegationContractToStep(args: { - stepId: string | null; - contract: DelegationContract; - additionalMetadata?: Record<string, unknown> | null; - allowTerminal?: boolean; - }): void { - if (!args.stepId) return; - orchestratorService.updateStepMetadata({ - runId, - stepId: args.stepId, - metadata: { - delegationContract: args.contract, - delegationOwnerKind: "coordinator", - delegationIntent: args.contract.workerIntent, - delegationMode: args.contract.mode, - delegationScopeKey: args.contract.scope.key, - ...(args.additionalMetadata ?? {}), - }, - allowTerminal: args.allowTerminal, - }); - } - - function resolvePhaseStepType(phaseKey: string): string { - const normalized = phaseKey.trim().toLowerCase(); - if (normalized === "planning" || normalized === "analysis") return "planning"; - if ( - normalized === "development" - || normalized === "implementation" - || normalized.startsWith("development_") - || normalized.startsWith("implementation_") - || normalized.endsWith("_development") - || normalized.endsWith("_implementation") - ) return "implementation"; - if (normalized === "testing" || normalized === "test") return "testing"; - if (normalized === "validation") return "validation"; - if (normalized === "code_review" || normalized === "review") return "review"; - if (normalized === "test_review") return "test_review"; - if (normalized === "integration" || normalized === "merge") return "integration"; - return normalized || "implementation"; - } - - function resolveCurrentPhaseCard( - g: OrchestratorRunGraph, - phases: PhaseCard[] - ): PhaseCard | null { - if (phases.length === 0) return null; - const runMeta = asRecord(g.run.metadata); - const phaseRuntime = asRecord(runMeta?.phaseRuntime); - const runtimePhaseKey = typeof phaseRuntime?.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - const runtimePhaseName = typeof phaseRuntime?.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - const sorted = [...phases].sort((a, b) => a.position - b.position); - if (runtimePhaseKey.length > 0) { - const byKey = sorted.find((phase) => phase.phaseKey === runtimePhaseKey); - if (byKey) return byKey; - } - if (runtimePhaseName.length > 0) { - const byName = sorted.find((phase) => phase.name === runtimePhaseName); - if (byName) return byName; - } - return sorted[0] ?? null; - } - - function buildPhaseMetadataForNewStep( - g: OrchestratorRunGraph, - phases: PhaseCard[] - ): Record<string, unknown> { - const current = resolveCurrentPhaseCard(g, phases); - if (!current) return {}; - return buildPhaseMetadataForPhase(current); - } - - function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; - } - - function getMissionPromptForPlanningPolicy(): string | null { - const candidates: string[] = []; - const addCandidate = (value: unknown) => { - const candidate = nonEmptyString(value); - if (candidate && !candidates.includes(candidate)) candidates.push(candidate); - }; - try { - const getMission = (missionService as { get?: (id: string) => { prompt?: unknown } | null }).get; - if (typeof getMission === "function") { - const mission = getMission.call(missionService, missionId); - addCandidate(mission?.prompt); - } - } catch { - // Fall back to persisted rows below. - } - try { - const missionPromptRow = projectId - ? db.get<{ prompt?: unknown }>( - `select prompt from missions where id = ? and project_id = ? limit 1`, - [missionId, projectId], - ) - : db.get<{ prompt?: unknown }>( - `select prompt from missions where id = ? limit 1`, - [missionId], - ); - addCandidate(missionPromptRow?.prompt); - } catch { - // Fall back to run metadata below. - } - try { - const runMetadataRow = db.get<{ metadata_json?: unknown }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId], - ); - const metadataJson = nonEmptyString(runMetadataRow?.metadata_json); - if (metadataJson) { - const metadata = asRecord(JSON.parse(metadataJson)); - addCandidate(metadata?.missionPrompt); - addCandidate(metadata?.missionGoal); - } - } catch { - // Fall back to the service graph below. - } - try { - const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const runMetadata = asRecord(graph.run.metadata); - addCandidate(runMetadata?.missionPrompt); - addCandidate(runMetadata?.missionGoal); - } catch { - // Fall back to accumulated candidates below. - } - return candidates.length > 0 ? candidates.join("\n\n") : null; - } - - function resolvePhaseCardForHint(phases: PhaseCard[], hint: WorkerPhaseHint | null): PhaseCard | null { - if (!hint) return null; - const wantedStepType = hint === "implementation" ? "implementation" : hint; - const aliases = new Set( - hint === "implementation" - ? ["implementation", "development", "code"] - : hint === "testing" - ? ["testing", "test"] - : hint === "validation" - ? ["validation", "validator"] - : hint === "integration" - ? ["integration", "merge", "result lane"] - : ["planning", "analysis"] - ); - return phases.find((phase) => { - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - return aliases.has(phaseKey) - || aliases.has(phaseName) - || resolvePhaseStepType(phase.phaseKey) === wantedStepType; - }) ?? null; - } - - function buildFallbackPhaseMetadataForHint(hint: WorkerPhaseHint | null): Record<string, unknown> { - if (!hint) return {}; - const phaseKey = hint === "implementation" ? "development" : hint; - const phaseName = - hint === "implementation" - ? "Development" - : hint.charAt(0).toUpperCase() + hint.slice(1); - const stepType = resolvePhaseStepType(phaseKey); - return { - phaseKey, - phaseName, - stepType, - taskType: stepType, - readOnlyExecution: stepType === "planning", - }; - } - - function buildPhaseMetadataForWorkerRequest(args: { - graph: OrchestratorRunGraph; - phases: PhaseCard[]; - phaseOverride?: PhaseCard | null; - phaseHint?: WorkerPhaseHint | null; - policyPrompt?: string | null; - }): Record<string, unknown> { - if (args.phaseOverride) return buildPhaseMetadataForPhase(args.phaseOverride, args.policyPrompt); - const hintedPhase = resolvePhaseCardForHint(args.phases, args.phaseHint ?? null); - if (hintedPhase) return buildPhaseMetadataForPhase(hintedPhase, args.policyPrompt); - const hintedFallback = buildFallbackPhaseMetadataForHint(args.phaseHint ?? null); - if (Object.keys(hintedFallback).length > 0) return hintedFallback; - return buildPhaseMetadataForNewStep(args.graph, args.phases); - } - - function buildPhaseMetadataForPhase(current: PhaseCard, policyPrompt?: string | null): Record<string, unknown> { - const stepType = resolvePhaseStepType(current.phaseKey); - const missionPrompt = [getMissionPromptForPlanningPolicy(), policyPrompt] - .map((entry) => nonEmptyString(entry)) - .filter((entry): entry is string => Boolean(entry)) - .join("\n\n") || null; - const requiredBeforeExit = phaseRequiresPlanningQuestionBeforeExit({ - phase: current, - missionPrompt, - }); - const maxQuestions = current.askQuestions.enabled === true - ? current.askQuestions.maxQuestions == null - ? null - : Math.max(1, Math.min(10, Number(current.askQuestions.maxQuestions) || 5)) - : undefined; - return { - phaseKey: current.phaseKey, - phaseName: current.name, - phasePosition: current.position, - phaseModel: current.model, - phaseInstructions: current.instructions, - phaseAskQuestions: { - enabled: current.askQuestions.enabled === true, - maxQuestions, - requiredBeforeExit, - }, - phaseValidation: current.validationGate, - phaseBudget: current.budget ?? {}, - stepType, - taskType: stepType, - readOnlyExecution: stepType === "planning" - }; - } - - function applyPlanningQuestionPolicyToWorkerMetadata(args: { - metadata: Record<string, unknown>; - phases: PhaseCard[]; - policyPrompt?: string | null; - }): Record<string, unknown> { - const metadata = args.metadata; - const phaseKey = typeof metadata.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - const phaseName = typeof metadata.phaseName === "string" ? metadata.phaseName.trim().toLowerCase() : ""; - if (phaseKey !== "planning" && phaseName !== "planning") return metadata; - const phaseAskQuestions = asRecord(metadata.phaseAskQuestions); - if (phaseAskQuestions?.enabled !== true || phaseAskQuestions.requiredBeforeExit === true) return metadata; - const phase = args.phases.find((entry) => - entry.phaseKey.trim().toLowerCase() === phaseKey - || entry.name.trim().toLowerCase() === phaseName - ) ?? null; - const phaseForPolicy = { - ...(phase ?? {}), - phaseKey: typeof metadata.phaseKey === "string" && metadata.phaseKey.trim().length > 0 - ? metadata.phaseKey.trim() - : phase?.phaseKey ?? "planning", - name: typeof metadata.phaseName === "string" && metadata.phaseName.trim().length > 0 - ? metadata.phaseName.trim() - : phase?.name ?? "Planning", - instructions: [ - typeof phase?.instructions === "string" ? phase.instructions : "", - typeof metadata.phaseInstructions === "string" ? metadata.phaseInstructions : "", - ] - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .join("\n"), - askQuestions: { - ...(phase?.askQuestions ?? {}), - ...phaseAskQuestions, - enabled: true, - requiredBeforeExit: phaseAskQuestions.requiredBeforeExit === true, - }, - } as PhaseCard; - const missionPrompt = [getMissionPromptForPlanningPolicy(), args.policyPrompt] - .map((entry) => nonEmptyString(entry)) - .filter((entry): entry is string => Boolean(entry)) - .join("\n\n") || null; - const requiredBeforeExit = phaseRequiresPlanningQuestionBeforeExit({ - phase: phaseForPolicy, - missionPrompt, - }); - if (!requiredBeforeExit) return metadata; - return { - ...metadata, - phaseAskQuestions: { - ...phaseAskQuestions, - requiredBeforeExit: true, - }, - }; - } - - function normalizePhaseMatchToken(value: unknown): string { - return String(value ?? "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - } - - function resolveTaskPhaseMetadata(args: { - phases: PhaseCard[]; - requestedPhaseKey?: string | null; - key: string; - title: string; - fallbackMetadata: Record<string, unknown>; - }): { ok: true; metadata: Record<string, unknown> } | { ok: false; error: string } { - if (args.phases.length === 0) return { ok: true, metadata: args.fallbackMetadata }; - const requestedPhaseKey = typeof args.requestedPhaseKey === "string" ? args.requestedPhaseKey.trim() : ""; - if (requestedPhaseKey.length > 0) { - const requested = args.phases.find((phase) => - phase.phaseKey === requestedPhaseKey - || phase.name === requestedPhaseKey - || phase.phaseKey.toLowerCase() === requestedPhaseKey.toLowerCase() - || phase.name.toLowerCase() === requestedPhaseKey.toLowerCase() - ); - if (!requested) { - return { - ok: false, - error: `Unknown phase '${requestedPhaseKey}'. Available phases: ${args.phases.map((phase) => phase.phaseKey).join(", ")}` - }; - } - return { ok: true, metadata: buildPhaseMetadataForPhase(requested) }; - } - - const keyToken = normalizePhaseMatchToken(args.key); - const titleToken = normalizePhaseMatchToken(args.title); - const inferred = args.phases.find((phase) => { - const phaseKeyToken = normalizePhaseMatchToken(phase.phaseKey); - const phaseNameToken = normalizePhaseMatchToken(phase.name); - return Boolean( - (phaseKeyToken && (keyToken === phaseKeyToken || keyToken.startsWith(`${phaseKeyToken}-`) || titleToken.startsWith(`${phaseKeyToken}-`))) - || (phaseNameToken && (keyToken === phaseNameToken || keyToken.startsWith(`${phaseNameToken}-`) || titleToken.startsWith(`${phaseNameToken}-`))) - ); - }); - return { ok: true, metadata: inferred ? buildPhaseMetadataForPhase(inferred) : args.fallbackMetadata }; - } - - function appendPlanningResultContract( - prompt: string, - phaseMetadata: Record<string, unknown> - ): string { - const stepType = typeof phaseMetadata.stepType === "string" ? phaseMetadata.stepType.trim().toLowerCase() : ""; - const phaseKey = typeof phaseMetadata.phaseKey === "string" ? phaseMetadata.phaseKey.trim().toLowerCase() : ""; - const phaseName = typeof phaseMetadata.phaseName === "string" ? phaseMetadata.phaseName.trim().toLowerCase() : ""; - const isPlanningWorker = stepType === "planning" || phaseKey === "planning" || phaseName === "planning"; - if (!isPlanningWorker) return prompt; - if (/Planning worker report contract/i.test(prompt)) return prompt; - - return [ - prompt.trim(), - [ - "## Planning worker report contract", - "This is a Planning phase worker. ADE accepts a successful planning worker only when the result includes a first-class plan payload.", - "Before exiting, call `report_result` with `outcome`, `summary`, `filesChanged`, `testsRun`, and `plan.markdown`.", - "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: <one sentence>", - "- filesChanged: []", - "- testsRun: <commands run, or read-only checks only>", - "- plan.markdown:", - " ```markdown", - " ## Plan", - " - What was learned", - " - Recommended next steps", - " - Risks or stop conditions", - " ```", - "For smoke or read-only proof missions, the plan can be short, but it still must be present in `plan.markdown`.", - "Do not finish with only Accomplished, Changed Files, Tests Run, or Risks / Notes sections." - ].join("\n") - ].join("\n\n"); - } - - function getStepsForPhase(g: OrchestratorRunGraph, phase: PhaseCard): OrchestratorStep[] { - return g.steps.filter((step) => { - const stepMeta = asRecord(step.metadata); - const stepPhaseKey = typeof stepMeta?.phaseKey === "string" ? stepMeta.phaseKey.trim() : ""; - const stepPhaseName = typeof stepMeta?.phaseName === "string" ? stepMeta.phaseName.trim() : ""; - return stepPhaseKey === phase.phaseKey || stepPhaseName === phase.name; - }); - } - - function stepBelongsToPhase(step: OrchestratorStep, phase: PhaseCard): boolean { - const stepMeta = asRecord(step.metadata); - const stepPhaseKey = typeof stepMeta?.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepPhaseName = typeof stepMeta?.phaseName === "string" ? stepMeta.phaseName.trim().toLowerCase() : ""; - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - return stepPhaseKey === phaseKey || stepPhaseName === phaseName; - } - - function isImplicitDependencyCandidate(step: OrchestratorStep): boolean { - if (isTaskShellStep(step)) return false; - return step.status === "succeeded" || step.status === "running" || step.status === "blocked"; - } - - function resolveImplicitDependencyStepKeys( - g: OrchestratorRunGraph, - phases: PhaseCard[], - ): string[] { - const currentPhase = resolveCurrentPhaseCard(g, phases); - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - const currentPhaseName = currentPhase?.name.trim().toLowerCase() ?? ""; - if (!currentPhase || currentPhaseKey === "planning" || currentPhaseName === "planning") { - return []; - } - - const sorted = [...phases].sort((a, b) => a.position - b.position); - const currentIndex = sorted.findIndex((phase) => phase.id === currentPhase.id); - if (currentIndex > 0) { - for (let index = currentIndex - 1; index >= 0; index -= 1) { - const priorPhase = sorted[index]; - if (!priorPhase) continue; - const priorPhaseStepKeys = filterExecutionSteps(g.steps) - .filter((step) => stepBelongsToPhase(step, priorPhase)) - .filter(isImplicitDependencyCandidate) - .map((step) => step.stepKey); - if (priorPhaseStepKeys.length > 0) { - return dedupeKeys(priorPhaseStepKeys); - } - } - } - - return filterExecutionSteps(g.steps) - .filter(isPlanningExecutionStep) - .filter(isImplicitDependencyCandidate) - .map((step) => step.stepKey); - } - - function getPlanningInputBlockReason(g: OrchestratorRunGraph): string | null { - const phases = resolveMissionPhases(g); - const current = resolveCurrentPhaseCard(g, phases); - const currentKey = current?.phaseKey.trim().toLowerCase() ?? ""; - const currentName = current?.name.trim().toLowerCase() ?? ""; - if (currentKey !== "planning" && currentName !== "planning") return null; - const mission = missionService.get(missionId); - if (!mission) return null; - const blocking = mission.interventions.filter((entry) => { - if (entry.status !== "open" || entry.interventionType !== "manual_input") return false; - const metadata = asRecord(entry.metadata); - if (metadata?.canProceedWithoutAnswer === true) return false; - const phase = typeof metadata?.phase === "string" ? metadata.phase.trim().toLowerCase() : ""; - return phase.length === 0 || phase === "planning" || phase === currentKey || phase === currentName; - }); - if (blocking.length === 0) return null; - return `Planning input is still pending. Resolve ${blocking.length === 1 ? "the open question" : "the open questions"} before executing planning actions.`; - } - - function resolveCurrentPhaseQuestionPolicy(g: OrchestratorRunGraph): { - phase: PhaseCard | null; - phaseKey: string; - enabled: boolean; - maxQuestions: number | null; - isPlanning: boolean; - } { - const phases = resolveMissionPhases(g); - const current = resolveCurrentPhaseCard(g, phases); - const currentKey = current?.phaseKey.trim().toLowerCase() ?? ""; - const currentName = current?.name.trim().toLowerCase() ?? ""; - const inPlanning = currentKey === "planning" || currentName === "planning"; - return { - phase: current ?? null, - phaseKey: current?.phaseKey ?? current?.name ?? "", - enabled: current?.askQuestions.enabled === true, - maxQuestions: inPlanning && current?.askQuestions.maxQuestions == null - ? null - : Math.max(1, Math.min(10, Number(current?.askQuestions.maxQuestions ?? 5) || 5)), - isPlanning: inPlanning, - }; - } - - function resolvePlanningQuestionPolicy(g: OrchestratorRunGraph): { - phase: PhaseCard | null; - phaseKey: string; - enabled: boolean; - maxQuestions: number | null; - } { - const policy = resolveCurrentPhaseQuestionPolicy(g); - return { - phase: policy.phase, - phaseKey: policy.phaseKey, - enabled: policy.isPlanning && policy.enabled, - maxQuestions: policy.maxQuestions, - }; - } - - function listPhaseQuestionInterventions(g: OrchestratorRunGraph): import("../../../shared/types").MissionIntervention[] { - const mission = missionService.get(missionId); - if (!mission) return []; - const policy = resolveCurrentPhaseQuestionPolicy(g); - const normalizedPhase = policy.phaseKey.trim().toLowerCase(); - return mission.interventions.filter((entry) => { - if (entry.interventionType !== "manual_input") return false; - const metadata = asRecord(entry.metadata); - if (!isPlanningQuestionIntervention(entry, { runId })) return false; - const phase = typeof metadata?.phase === "string" ? metadata.phase.trim().toLowerCase() : ""; - const phaseKey = typeof metadata?.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - const phaseMatches = phase.length === 0 || phase === "planning" || phase === normalizedPhase; - const phaseKeyMatches = phaseKey.length === 0 || phaseKey === "planning" || phaseKey === normalizedPhase; - return phaseMatches && phaseKeyMatches; - }); - } - - function getPlanningQuestionPolicyBlockReason(g: OrchestratorRunGraph): string | null { - const policy = resolvePlanningQuestionPolicy(g); - if (!policy.enabled) return null; - const interventions = listPhaseQuestionInterventions(g); - const openQuestion = interventions.find((entry) => entry.status === "open") ?? null; - if (openQuestion) { - return "Planning questions are still open. Resolve them before spawning the planning worker."; - } - return null; - } - - function getQuestionAskingBlockReason(g: OrchestratorRunGraph): string | null { - const policy = resolveCurrentPhaseQuestionPolicy(g); - if (policy.enabled) return null; - return "Ask Questions is disabled for the current phase. Proceed with the best reasonable assumption unless runtime opens its own intervention."; - } - - function buildCoordinatorQuestionOwnerMetadata(args: { - phaseKey?: string | null; - phaseName?: string | null; - }): Record<string, unknown> { - return { - questionOwnerKind: "coordinator", - questionOwnerLabel: "Coordinator question", - ownerRole: "coordinator", - phase: args.phaseKey ?? null, - phaseName: args.phaseName ?? null, - }; - } - - function getPlanningRepoReadBlockReason(g: OrchestratorRunGraph): string | null { - const permission = checkCoordinatorToolPermission({ - toolName: "read_file", - contracts: extractActiveDelegationContracts(g), - }); - if (!permission.allowed) { - return permission.reason; - } - const phases = resolveMissionPhases(g); - const current = resolveCurrentPhaseCard(g, phases); - const currentKey = current?.phaseKey.trim().toLowerCase() ?? ""; - const currentName = current?.name.trim().toLowerCase() ?? ""; - if (currentKey === "planning" || currentName === "planning") { - return "Coordinator-side repo inspection is disabled during Planning. Use get_project_context to brief the planner, wait for the planning worker output, then transition phases before doing coordinator-side file reads."; - } - return null; - } - - function promptContainsImplementationDirectives(prompt: string): boolean { - const compact = prompt.trim(); - if (!compact.length) return false; - const normalized = compact.toLowerCase(); - return ( - normalized.includes("all file edits must") - || normalized.includes("files to change") - || normalized.includes("files to modify") - || normalized.includes("files to edit") - || normalized.includes("exact changes required") - || normalized.includes("i have already researched the codebase") - || normalized.includes("here is the complete plan") - || normalized.includes("please output a brief confirmation of this plan") - || normalized.includes("create new file") - || normalized.includes("new file:") - || normalized.includes("run `git add") - || normalized.includes("run `git commit") - || normalized.includes("done criteria") - || normalized.includes("apply in the worktree") - || /(?:^|\n)###\s*\d+\.\s*(?:create|edit)\b/i.test(compact) - || /(?:^|\n)\d+\.\s+new file:/i.test(compact) - || /(?:^|\n)-\s*add\b.+\bimport\b/i.test(compact) - || /(?:^|\n)-\s*add\b.+\broute\b/i.test(compact) - || /(?:^|\n)-\s*edit\s+`[^`]+`/i.test(compact) - ); - } - - function looksLikeValidationWorkerRequest(args: { - name: string; - roleName: string | null; - prompt: string; - validationContract: ValidationContract | null; - includeNameHeuristics?: boolean; - includePromptHeuristics?: boolean; - }): boolean { - if (args.validationContract?.level === "step" || args.validationContract?.level === "milestone" || args.validationContract?.level === "mission") { - return true; - } - const includeNameHeuristics = args.includeNameHeuristics ?? true; - const includePromptHeuristics = args.includePromptHeuristics ?? true; - 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)|review(?:er|ing)?|audit|proof)\b/; - return ( - (includeNameHeuristics && validationPattern.test(name)) - || validationPattern.test(roleName) - || (includePromptHeuristics && prompt.includes("report_validation")) - || (includePromptHeuristics && prompt.includes("verdict: \"pass\"")) - || (includePromptHeuristics && prompt.includes("verdict \"pass\"")) - ); - } - - function isPlanningExecutionStep(step: OrchestratorStep): boolean { - const stepMeta = asRecord(step.metadata); - const stepPhaseKey = typeof stepMeta?.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepPhaseName = typeof stepMeta?.phaseName === "string" ? stepMeta.phaseName.trim().toLowerCase() : ""; - return !isTaskShellStep(step) && (stepPhaseKey === "planning" || stepPhaseName === "planning"); - } - - function phaseHasSuccessfulCompletion( - phase: PhaseCard, - stepsForPhase: (phase: PhaseCard) => OrchestratorStep[], - ): boolean { - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - const phaseSteps = filterExecutionSteps(stepsForPhase(phase)); - if (phaseSteps.length === 0) return false; - if (phaseKey === "planning" || phaseName === "planning") { - return phaseSteps.some((step) => isPlanningExecutionStep(step) && step.status === "succeeded"); - } - const allTerminalWithoutFailure = phaseSteps.every( - (step) => step.status === "succeeded" || step.status === "skipped" || step.status === "superseded", - ); - const hasConcreteSuccess = phaseSteps.some((step) => step.status === "succeeded"); - return allTerminalWithoutFailure && hasConcreteSuccess; - } - - function phaseHasValidatedSuccessfulCompletion( - phase: PhaseCard, - stepsForPhase: (phase: PhaseCard) => OrchestratorStep[], - ): boolean { - if (!phaseHasSuccessfulCompletion(phase, stepsForPhase)) return false; - const phaseSteps = filterExecutionSteps(stepsForPhase(phase)); - const allRequiredValidationPassed = phaseSteps.every((step) => { - if (step.status !== "succeeded") return true; - if (isAutoSpawnedValidationProofStep(step)) return true; - const stepMeta = asRecord(step.metadata); - const validationContract = parseValidationContract(stepMeta?.validationContract ?? null); - const phaseRequiresValidation = phase.validationGate.required === true && phase.validationGate.tier !== "none"; - if (!validationContract?.required && !phaseRequiresValidation) return true; - const validationPassedAt = typeof stepMeta?.validationPassedAt === "string" ? stepMeta.validationPassedAt.trim() : ""; - return resolveValidationStateFromStepMetadata(stepMeta ?? {}) === "pass" || validationPassedAt.length > 0; - }); - return allRequiredValidationPassed; - } - - function stopReasonLooksLikeNormalCompletion(reason: string): boolean { - const normalized = reason.trim().toLowerCase(); - if (!normalized.length) return false; - return ( - normalized.includes("work is committed and complete") - || normalized.includes("proceed to validation") - || normalized.includes("normal completion") - || normalized.includes("finished successfully") - || normalized.includes("already complete") - ); - } - - function stopReasonIndicatesHardFailure(reason: string): boolean { - const normalized = reason.trim().toLowerCase(); - if (!normalized.length) return false; - return ( - normalized.includes("fatal") - || normalized.includes("panic") - || normalized.includes("crash") - || normalized.includes("exception") - || normalized.includes("process exited unexpectedly") - || normalized.includes("session exited unexpectedly") - || normalized.includes("hard failure") - || normalized.includes("unrecoverable error") - ); - } - - const RECENT_WORKER_ACTIVITY_STOP_GUARD_MS = 2 * 60_000; - - function readRecentWorkerActivityAtMs(attempt: OrchestratorAttempt): number | null { - const sessionId = typeof attempt.executorSessionId === "string" ? attempt.executorSessionId.trim() : ""; - try { - const row = db.get<{ latest_at: string | null }>( - ` - select max(created_at) as latest_at - from orchestrator_chat_messages - where run_id = ? - and role = 'worker' - and ( - attempt_id = ? - or source_session_id = ? - ) - `, - [runId, attempt.id, sessionId], - ); - const parsed = typeof row?.latest_at === "string" ? Date.parse(row.latest_at) : Number.NaN; - return Number.isFinite(parsed) ? parsed : null; - } catch { - return null; - } - } - - /** - * Validate that spawning a worker for the current phase respects ordering constraints. - * - * Checks: - * 1. `mustFollow` — named predecessor phases must have at least one terminal step. - * 2. Required earlier phases (by position) must have at least one terminal step. - * 3. `mustBeFirst` — that phase must complete before any other phase starts work. - * 4. `mustBeLast` — all earlier phases must be fully terminal before it can start. - */ - function validatePhaseOrdering( - phases: PhaseCard[], - g: OrchestratorRunGraph, - ): - | { valid: true } - | { - valid: false; - reason: string; - phaseTransitionRequiredKey?: string | null; - phaseTransitionRequiredReason?: string | null; - } { - if (phases.length === 0) return { valid: true }; - const phaseContext = resolveConfiguredPhaseContext(g, phases); - if (!phaseContext.ok) { - return { valid: false, reason: phaseContext.error }; - } - if (!phaseContext.currentPhase) return { valid: true }; - const sorted = phaseContext.phases; - const currentPhase = phaseContext.currentPhase; - - const currentIndex = sorted.indexOf(currentPhase); - - 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)); - - const currentPhaseKeyNormalized = currentPhase.phaseKey.trim().toLowerCase(); - const currentPhaseNameNormalized = currentPhase.name.trim().toLowerCase(); - if (currentPhaseKeyNormalized === "planning" || currentPhaseNameNormalized === "planning") { - const planningExecutionSteps = filterExecutionSteps(getStepsForPhase(g, currentPhase)); - if (planningExecutionSteps.some((step) => !TERMINAL_STEP_STATUSES.has(step.status))) { - return { - valid: false, - reason: "Planning already has an active worker. Wait for it to finish before spawning another planning worker.", - }; - } - if (planningExecutionSteps.some((step) => step.status === "succeeded")) { - 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.`, - phaseTransitionRequiredKey: nextPhaseKey, - phaseTransitionRequiredReason: "planning_complete", - }; - } - } - - // Check mustFollow constraints - const explicitMustFollow = (currentPhase.orderingConstraints.mustFollow ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - for (const predecessor of explicitMustFollow) { - const predecessorPhase = findPhaseByRef(sorted, predecessor); - if (predecessorPhase && !phaseHasSucceeded(predecessorPhase)) { - return { - valid: false, - reason: `Phase "${currentPhase.name}" requires phase "${predecessorPhase.name}" to succeed first (mustFollow constraint). No successful completion was found for "${predecessorPhase.name}".`, - }; - } - } - - const mustPrecedePredecessors = resolveMustPrecedePredecessors(sorted, currentPhase); - for (const predecessorPhase of mustPrecedePredecessors) { - if (!phaseHasValidatedSucceeded(predecessorPhase)) { - return { - valid: false, - reason: `Phase "${currentPhase.name}" cannot start until phase "${predecessorPhase.name}" succeeds with required validation complete (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++) { - const earlier = sorted[i]; - const mustComplete = earlier.orderingConstraints.mustBeFirst || (explicitMustFollow.length === 0 && earlier.validationGate.required); - if (!mustComplete) continue; - if (!phaseHasSucceeded(earlier)) { - return { - valid: false, - reason: `Required phase "${earlier.name}" (position ${earlier.position}) has not succeeded yet. It must succeed before starting phase "${currentPhase.name}" (position ${currentPhase.position}).`, - }; - } - } - - // Check mustBeLast: all earlier phases must be fully terminal - if (currentPhase.orderingConstraints.mustBeLast) { - for (let i = 0; i < currentIndex; i++) { - const earlier = sorted[i]; - if (phaseHasNonTerminalStep(earlier)) { - return { - valid: false, - reason: `Phase "${currentPhase.name}" is marked mustBeLast but phase "${earlier.name}" still has active (non-terminal) steps.`, - }; - } - } - } - - // Check mustBeFirst: if a phase is mustBeFirst, it must succeed before others start - const firstPhase = sorted.find((p) => p.orderingConstraints.mustBeFirst); - if (firstPhase && firstPhase !== currentPhase && !phaseHasSucceeded(firstPhase)) { - return { - valid: false, - reason: `Phase "${firstPhase.name}" is marked mustBeFirst and has not succeeded yet. Cannot start phase "${currentPhase.name}" until it succeeds.`, - }; - } - - return { valid: true }; - } - - function stepHasPassingRequiredValidation(step: OrchestratorStep): boolean { - if (isAutoSpawnedValidationProofStep(step)) return true; - const stepMeta = asRecord(step.metadata) ?? {}; - const validationContract = parseValidationContract(stepMeta.validationContract ?? null); - if (!validationContract?.required) return true; - if (resolveValidationStateFromStepMetadata(stepMeta) === "pass") return true; - const validationPassedAt = typeof stepMeta.validationPassedAt === "string" ? stepMeta.validationPassedAt.trim() : ""; - return validationPassedAt.length > 0; - } - - function validateRequiredValidationGates( - phases: PhaseCard[], - g: OrchestratorRunGraph, - ): { valid: true } | { valid: false; reason: string } { - if (phases.length === 0) return { valid: true }; - const phaseContext = resolveConfiguredPhaseContext(g, phases); - if (!phaseContext.ok) { - return { valid: false, reason: phaseContext.error }; - } - if (!phaseContext.currentPhase) return { valid: true }; - const sorted = phaseContext.phases; - const currentPhase = phaseContext.currentPhase; - const currentIndex = sorted.indexOf(currentPhase); - - for (let i = 0; i < currentIndex; i += 1) { - const earlier = sorted[i]!; - if (!earlier.validationGate.required) continue; - const missingRequiredValidation = getStepsForPhase(g, earlier) - .filter((step) => step.status === "succeeded") - .filter((step) => !stepHasPassingRequiredValidation(step)); - if (missingRequiredValidation.length > 0) { - return { - valid: false, - reason: `Phase "${earlier.name}" validation gate has not passed. ${missingRequiredValidation.length} step(s) are missing required validation.`, - }; - } - } - - return { valid: true }; - } - - const spawn_worker = defineCoordinatorTool({ - description: - "Spawn a new agent worker session. The worker will execute the given prompt autonomously. Returns a worker ID (step key) you can use to track, message, or stop the worker.", - inputSchema: z.object({ - name: z.string().optional().describe("Human-readable name for the worker (e.g. 'auth-implementer', 'test-writer'). ADE derives one when omitted."), - taskKey: z.string().optional().describe("Optional existing create_task key to convert into this executable worker step."), - modelId: z.string().optional().describe("Optional model ID override for this worker (for example: openai/gpt-5.3-codex)"), - role: z.string().optional().describe("Optional team role to bind (e.g. implementer, validator, researcher)"), - prompt: z.string().describe("The full task prompt for the worker — be specific about what to do"), - readOnly: z.boolean().optional().describe("When true, run the worker with read-only execution permissions even outside Planning."), - laneId: z.string().optional().describe("Optional lane ID override for the worker step"), - replacementForWorkerId: z - .string() - .optional() - .describe("Optional source worker step key to replace. When set, lane and handoff context are inherited."), - replacementReason: z.string().optional().describe("Optional reason for replacement"), - validationContract: VALIDATION_CONTRACT_SCHEMA - .describe("Optional validation contract attached to this worker step"), - dependsOn: z - .array(z.string()) - .default([]) - .describe("Step keys this worker depends on (must complete before worker starts)"), - }), - execute: async ({ name, taskKey, modelId, role, prompt, readOnly, laneId, replacementForWorkerId, replacementReason, validationContract, dependsOn }) => { - try { - const g = graph(); - const planningInputBlockReason = getPlanningInputBlockReason(g); - if (planningInputBlockReason) { - return { ok: false, error: planningInputBlockReason }; - } - const planningQuestionPolicyBlockReason = getPlanningQuestionPolicyBlockReason(g); - if (planningQuestionPolicyBlockReason) { - return { ok: false, error: planningQuestionPolicyBlockReason }; - } - const normalizedPrompt = normalizeText(prompt); - if (!normalizedPrompt.length) { - return { ok: false, error: "Worker prompt is required." }; - } - const normalizedName = normalizeText(name) || inferWorkerNameFromPrompt(normalizedPrompt); - const normalizedTaskKey = typeof taskKey === "string" ? taskKey.trim() : ""; - if (normalizedTaskKey.length > 0) { - const taskStep = resolveStep(g, normalizedTaskKey); - if (!taskStep) return { ok: false, error: `Task not found: ${normalizedTaskKey}` }; - if (!isTaskShellStep(taskStep)) { - return { ok: false, error: `Step '${normalizedTaskKey}' is not a task shell.` }; - } - if (TERMINAL_STEP_STATUSES.has(taskStep.status)) { - return { ok: false, error: `Task '${normalizedTaskKey}' is already terminal (${taskStep.status}).` }; - } - } - const requestedDependsOn = normalizeDependencyReferences({ - graph: g, - refs: dependsOn, - label: `Worker '${normalizedName}'`, - }); - const teamRuntime = resolveTeamRuntimeConfig(g); - const normalizedRole = typeof role === "string" ? role.trim() : ""; - if (normalizedRole.length > 0 && !resolveRoleDefinition(teamRuntime, normalizedRole)) { - return { ok: false, error: `Unknown role '${normalizedRole}' in active team template.` }; - } - const replacementSourceWorkerId = typeof replacementForWorkerId === "string" ? replacementForWorkerId.trim() : ""; - if (replacementSourceWorkerId.length > 0 && !resolveStep(g, replacementSourceWorkerId)) { - return { ok: false, error: `Replacement source worker '${replacementSourceWorkerId}' was not found.` }; - } - const parsedContract = parseValidationContract(validationContract ?? null); - if (validationContract && !parsedContract) { - return { ok: false, error: "Invalid validationContract payload." }; - } - const missionPhases = resolveMissionPhases(g); - const phaseContext = resolveConfiguredPhaseContext(g, missionPhases); - if (!phaseContext.ok) { - return { ok: false, error: phaseContext.error }; - } - const currentPhase = phaseContext.currentPhase; - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - const inferredPhaseHint = inferWorkerPhaseHintFromRequest({ - name: normalizedName, - roleName: normalizedRole.length > 0 ? normalizedRole : null, - prompt: normalizedPrompt, - }); - const inferredPhase = resolvePhaseCardForHint(missionPhases, inferredPhaseHint); - const targetPhase = inferredPhase ?? currentPhase; - const targetPhaseKey = - targetPhase?.phaseKey.trim().toLowerCase() - ?? inferredPhaseHint - ?? currentPhaseKey; - const targetPhaseLabel = - targetPhase?.name - ?? targetPhase?.phaseKey - ?? (targetPhaseKey.length > 0 ? targetPhaseKey : null); - const inferredDependsOn = - requestedDependsOn.length === 0 && missionPhases.length > 0 - ? resolveImplicitDependencyStepKeys(g, missionPhases) - : []; - const normalizedDependsOn = - requestedDependsOn.length > 0 ? requestedDependsOn : inferredDependsOn; - const phaseValidation = validateDelegationPromptForCurrentPhase({ - currentPhaseKey, - currentPhase, - nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), - workerName: normalizedName, - roleName: normalizedRole.length > 0 ? normalizedRole : null, - prompt: normalizedPrompt, - validationContract: parsedContract, - validationHeuristics: "strict", - }); - if (!phaseValidation.ok) { - return { ok: false, error: phaseValidation.error }; - } - const spawnPolicy = authorizeWorkerSpawnPolicy({ - g, - requestedModelId: modelId, - }); - if (!spawnPolicy.ok) { - if (spawnPolicy.blockedByValidationGate) { - logger.info("coordinator.spawn_worker.validation_gate_blocked", { - name: normalizedName, - reason: spawnPolicy.error, - }); - const gateBlockedAt = nowIso(); - const graphStep = resolveStep( - g, - replacementSourceWorkerId.length > 0 - ? replacementSourceWorkerId - : requestedDependsOn[requestedDependsOn.length - 1] ?? "", - ); - const gateBlockedDetail = { - workerName: normalizedName, - requestedRole: normalizedRole.length > 0 ? normalizedRole : null, - phase: resolveCurrentPhase(g), - reason: spawnPolicy.error, - blockedByValidationGate: true, - laneId: typeof laneId === "string" && laneId.trim().length > 0 ? laneId.trim() : null, - stepKey: graphStep?.stepKey ?? null - }; - orchestratorService.appendTimelineEvent({ - runId, - stepId: graphStep?.id ?? null, - eventType: "validation_gate_blocked", - reason: "required_validation_gate_blocked", - detail: gateBlockedDetail - }); - orchestratorService.appendRuntimeEvent({ - runId, - stepId: graphStep?.id ?? null, - eventType: "validation_gate_blocked", - eventKey: `validation_gate_blocked:${runId}:${normalizedName}:${normalizedRole}:${gateBlockedAt}`, - occurredAt: gateBlockedAt, - payload: gateBlockedDetail - }); - orchestratorService.emitRuntimeUpdate({ - runId, - stepId: graphStep?.id ?? null, - reason: "validation_gate_blocked" - }); - } else if (spawnPolicy.blockedByPhaseOrdering) { - logger.info("coordinator.spawn_worker.phase_ordering_blocked", { - name: normalizedName, - reason: spawnPolicy.error, - phaseTransitionRequiredKey: spawnPolicy.phaseTransitionRequiredKey ?? null, - }); - } - if (spawnPolicy.blockedByPhaseOrdering) { - return phaseOrderingBlockedResult( - spawnPolicy.error, - spawnPolicy.phaseTransitionRequiredKey, - spawnPolicy.phaseTransitionRequiredReason, - ); - } - 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() : ""; - const targetPhaseModelId = typeof targetPhase?.model?.modelId === "string" ? targetPhase.model.modelId.trim() : ""; - if (!explicitModelId && targetPhaseModelId.length > 0 && targetPhase?.phaseKey !== currentPhase?.phaseKey) { - const targetDescriptor = resolveModelDescriptor(targetPhaseModelId); - if (!targetDescriptor) { - return { ok: false, error: `Target phase model '${targetPhaseModelId}' is not registered.` }; - } - resolvedModelId = targetDescriptor.id; - resolvedProvider = resolveProviderGroupForModel(targetDescriptor); - } - - // Hard cap check: refuse to spawn if budget hard caps are triggered - const budgetCheck = await checkBudgetHardCaps({ - failClosedOnTelemetryError: true, - operation: "spawn_worker", - }); - if (budgetCheck.blocked) { - logger.warn("coordinator.spawn_worker.hard_cap_blocked", { name: normalizedName, detail: budgetCheck.detail }); - return { - ok: false, - error: `Cannot spawn worker: ${budgetCheck.detail}. Mission pausing.`, - hardCapTriggered: true, - hardCaps: budgetCheck.hardCaps, - }; - } - - // Emit soft budget warning on spawn if pressure is elevated (deduped) - if (deps.onBudgetWarning && getMissionBudgetStatus) { - try { - const snap = await getMissionBudgetStatus(); - if ( - snap && - (snap.pressure === "warning" || snap.pressure === "critical") && - snap.pressure !== lastEmittedBudgetPressure - ) { - const detail = snap.recommendation || `Budget pressure is now ${snap.pressure} while spawning worker '${normalizedName}'`; - lastEmittedBudgetPressure = snap.pressure; - deps.onBudgetWarning(snap.pressure, detail); - } - } catch { - // Non-blocking — budget warning is best-effort - } - } - - // ── VAL-USAGE-003: Model downgrade runtime ── - // Check if usage exceeds the configured model downgrade threshold. - // If so, override the resolved model ID with a cheaper alternative. - if (getMissionBudgetStatus) { - try { - const usageSnap = await getMissionBudgetStatus(); - if (usageSnap) { - const runMeta = asRecord(g.run.metadata); - const budgetConfig = asRecord(runMeta?.budgetConfig); - const thresholdPct = Number(budgetConfig?.modelDowngradeThresholdPct ?? 0); - if (thresholdPct > 0) { - const maxUsagePct = Math.max( - ...(usageSnap.perProvider ?? []).map((p: any) => - Math.max(Number(p.fiveHour?.usedPct ?? 0), Number(p.weekly?.usedPct ?? 0)) - ), - 0 - ); - if (maxUsagePct >= thresholdPct) { - const { evaluateModelDowngrade } = await import("./adaptiveRuntime"); - const downgradeResult = evaluateModelDowngrade({ - currentModelId: resolvedModelId, - downgradeThresholdPct: thresholdPct, - currentUsagePct: maxUsagePct, - }); - if (downgradeResult.downgraded) { - logger.info("coordinator.spawn_worker.model_downgrade", { - name: normalizedName, - originalModel: downgradeResult.originalModelId, - downgradedModel: downgradeResult.resolvedModelId, - reason: downgradeResult.reason, - usagePct: Math.round(maxUsagePct), - thresholdPct, - }); - resolvedModelId = downgradeResult.resolvedModelId; - } - } - } - } - } catch { - // Non-blocking — downgrade check is best-effort - } - } - - // Parallel agent enforcement: if disabled, block when a worker is already running - const teamRuntimeForPolicy = resolveTeamRuntimeConfig(g); - if (teamRuntimeForPolicy?.allowParallelAgents === false) { - const hasRunningAttempt = g.attempts.some((a) => a.status === "running"); - if (hasRunningAttempt) { - return { - ok: false, - error: "Parallel agents disabled — wait for current worker to complete before spawning another.", - }; - } - } - - // Phase ordering enforcement is handled centrally. Planning still gets - // a dedicated post-completion guard because only one planning worker result - // should exist before transitioning phases. - if (missionPhases.length > 0) { - if (currentPhaseKey === "planning") { - const completedPlanningWorker = g.steps.some((step) => { - const stepMeta = asRecord(step.metadata); - const stepPhaseKey = typeof stepMeta?.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepPhaseName = typeof stepMeta?.phaseName === "string" ? stepMeta.phaseName.trim().toLowerCase() : ""; - return !isTaskShellStep(step) - && (stepPhaseKey === "planning" || stepPhaseName === "planning") - && step.status === "succeeded"; - }); - if (completedPlanningWorker) { - 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, - "planning_complete", - ); - } - } - } - - const delegationIntent: DelegationIntent = - replacementSourceWorkerId.length > 0 - ? "recovery" - : targetPhaseKey === "planning" - ? "planner" - : targetPhaseKey === "validation" || parsedContract?.required - ? "validation" - : "implementation"; - const delegationMode: DelegationMode = - delegationIntent === "planner" - ? "exclusive" - : delegationIntent === "recovery" - ? "recovery" - : "bounded_parallel"; - const provisionalWorkerId = replacementSourceWorkerId.length > 0 - ? `replacement_${replacementSourceWorkerId}_${Date.now()}` - : `worker_${normalizedName.replace(/[^a-zA-Z0-9_-]/g, "_")}_${Date.now()}`; - const delegationScopeKey = - delegationIntent === "planner" - ? "phase:planning" - : replacementSourceWorkerId.length > 0 - ? `worker:${replacementSourceWorkerId}` - : targetPhaseKey.length > 0 - ? `phase:${targetPhaseKey}` - : `phase:${resolveCurrentPhase(g).trim().toLowerCase() || "unknown"}`; - const delegationContract = buildCoordinatorDelegationContract({ - graph: g, - workerId: provisionalWorkerId, - phaseKey: targetPhaseKey, - workerName: normalizedName, - intent: delegationIntent, - mode: delegationMode, - scopeKind: delegationIntent === "planner" ? "phase" : replacementSourceWorkerId.length > 0 ? "worker" : "phase", - scopeKey: delegationScopeKey, - scopeLabel: targetPhaseLabel, - metadata: { - role: normalizedRole.length > 0 ? normalizedRole : null, - replacementForWorkerId: replacementSourceWorkerId || null, - }, - }); - const conflictingContract = hasConflictingDelegationContract({ - graph: g, - contract: delegationContract, - }); - if (conflictingContract) { - return { - ok: false, - error: - `Delegation scope '${delegationContract.scope.key}' is already owned by active ${conflictingContract.mode} delegation ` + - `(${conflictingContract.workerIntent}). Wait for it to finish or recover explicitly before spawning more work in the same scope.`, - }; - } - - const { workerId, step: newStep, roleName, modelId: spawnedModelId, toolProfile, reusedExistingStep } = spawnWorkerStep({ - stepKey: normalizedTaskKey.length > 0 ? normalizedTaskKey : provisionalWorkerId, - taskKey: normalizedTaskKey.length > 0 ? normalizedTaskKey : null, - name: normalizedName, - modelId: resolvedModelId, - prompt: normalizedPrompt, - dependsOn: normalizedDependsOn, - requestedDependsOn, - inferredDependsOn, - roleName: normalizedRole.length > 0 ? normalizedRole : null, - laneId: typeof laneId === "string" && laneId.trim().length > 0 ? laneId.trim() : null, - readOnly: readOnly === true, - replacementForWorkerId: replacementSourceWorkerId || null, - replacementReason: replacementReason?.trim() || null, - validationContract: parsedContract, - phaseOverride: targetPhase ?? null, - phaseHint: inferredPhaseHint, - delegationContract: updateDelegationContract(delegationContract, { - activeWorkerIds: [provisionalWorkerId], - }), - }); - if (newStep && !reusedExistingStep) { - onDagMutation({ - runId, - mutation: { type: "step_added", step: newStep }, - timestamp: nowIso(), - source: "coordinator", - }); - } - - // Trigger autopilot to pick up the new step — await with timeout - // so we can tell the coordinator whether the worker actually launched. - let launched = false; - let launchNote: string | undefined; - try { - const startedCount = await Promise.race([ - orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_spawn_worker", - }), - new Promise<number>((_, reject) => - setTimeout(() => reject(new Error("autopilot_start_timeout")), AUTOPILOT_START_TIMEOUT_MS) - ), - ]); - // Verify an attempt is actually running for this step - if (newStep) { - const freshGraph = graph(); - const runningAttempt = freshGraph.attempts.find( - (a) => a.stepId === newStep.id && a.status === "running", - ); - launched = !!runningAttempt; - if (!launched && startedCount > 0) { - // Autopilot started attempts but not for this step (e.g. other ready steps got priority) - launchNote = "autopilot_started_other_steps"; - } else if (!launched) { - launchNote = "step_queued_not_yet_started"; - } - } else { - launched = startedCount > 0; - } - } catch { - // Autopilot didn't finish in time — step is created and will be picked up on next cycle - launchNote = "autopilot_start_timeout_step_queued"; - logger.warn("coordinator.spawn_worker.autopilot_timeout", { name: normalizedName, workerId }); - } - - trackTeamMember({ - workerId, - provider: resolvedProvider, - modelId: spawnedModelId, - role: roleName, - source: "ade-worker", - }); - - const finalizedDelegationContract = updateDelegationContract(delegationContract, { - activeWorkerIds: [workerId], - status: - delegationIntent === "planner" - ? "active" - : launched || delegationMode !== "exclusive" - ? "active" - : "launching", - launchState: - delegationIntent === "planner" - ? "waiting_on_worker" - : launched - ? "waiting_on_worker" - : "awaiting_worker_launch", - startedAt: launched ? nowIso() : null, - metadata: { - ...(delegationContract.metadata ?? {}), - launchNote: launchNote ?? null, - launched, - }, - }); - if (newStep?.id) { - applyDelegationContractToStep({ - stepId: newStep.id, - contract: finalizedDelegationContract, - }); - persistDelegationContract({ - contract: finalizedDelegationContract, - stepId: newStep.id, - reason: "delegation_contract_created", - action: "created", - }); - } - - logger.info("coordinator.spawn_worker", { - name: normalizedName, - workerId, - provider: resolvedProvider, - role: roleName, - launched, - launchNote, - delegationIntent, - delegationMode, - }); - return { - ok: true, - workerId, - launched, - ...(launchNote ? { launchNote } : {}), - stepId: newStep?.id ?? null, - status: newStep?.status ?? "unknown", - name: normalizedName, - modelId: spawnedModelId, - provider: resolvedProvider, - role: roleName, - toolProfile, - replacementForWorkerId: replacementSourceWorkerId || null, - delegationContract: finalizedDelegationContract, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.spawn_worker.error", { name, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const insert_milestone = defineCoordinatorTool({ - description: - "Insert a milestone gate into the mission DAG. Milestones require dedicated validator pass before they can be completed.", - inputSchema: z.object({ - name: z.string().describe("Human-readable milestone name"), - dependsOn: z - .array(z.string()) - .default([]) - .describe("Step keys this milestone depends on"), - validationCriteria: z.string().describe("Validation criteria that must pass for this milestone"), - gatesSteps: z - .array(z.string()) - .optional() - .describe("Optional step keys that should be gated on this milestone"), - }), - execute: async ({ name, dependsOn, validationCriteria, gatesSteps }) => { - try { - const g = graph(); - const normalizedName = normalizeText(name); - if (!normalizedName.length) { - return { ok: false, error: "Milestone name is required." }; - } - const normalizedCriteria = normalizeText(validationCriteria); - if (!normalizedCriteria.length) { - return { ok: false, error: "validationCriteria is required for insert_milestone." }; - } - - const normalizedDependsOn = normalizeDependencyReferences({ - graph: g, - refs: dependsOn, - label: `Milestone '${normalizedName}'`, - }); - - const normalizedGatesSteps = normalizeDependencyReferences({ - graph: g, - refs: gatesSteps, - label: `Milestone '${normalizedName}' gates`, - }); - - const maxIndex = g.steps.reduce( - (max, step) => Math.max(max, step.stepIndex), - -1, - ); - const slug = normalizedName - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "_") - .replace(/^_+|_+$/g, "") || "milestone"; - let milestoneStepKey = `milestone_${slug}_${Date.now()}`; - let disambiguator = 1; - while (resolveStep(g, milestoneStepKey)) { - disambiguator += 1; - milestoneStepKey = `milestone_${slug}_${Date.now()}_${disambiguator}`; - } - const milestoneContract: ValidationContract = { - level: "milestone", - tier: "dedicated", - required: true, - criteria: normalizedCriteria, - maxRetries: 2, - evidence: [] - }; - const created = orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: milestoneStepKey, - title: normalizedName, - stepIndex: maxIndex + 1, - dependencyStepKeys: normalizedDependsOn, - executorKind: "manual", - metadata: { - instructions: `Milestone gate: ${normalizedCriteria}`, - stepType: "milestone", - isMilestone: true, - milestoneValidationCriteria: normalizedCriteria, - validationContract: milestoneContract - } - } - ] - }); - const milestoneStep = created[0] ?? null; - if (!milestoneStep) { - return { ok: false, error: "Failed to create milestone step." }; - } - - onDagMutation({ - runId, - mutation: { type: "step_added", step: milestoneStep }, - timestamp: nowIso(), - source: "coordinator", - }); - - const gatedStepsPatched: Array<{ stepKey: string; dependencyStepKeys: string[] }> = []; - if (normalizedGatesSteps.length > 0) { - const refreshed = graph(); - const stepKeyById = new Map(refreshed.steps.map((step) => [step.id, step.stepKey] as const)); - for (const gatedStepKey of normalizedGatesSteps) { - const gatedStep = resolveStep(refreshed, gatedStepKey); - if (!gatedStep) continue; - const existingDependencyStepKeys = gatedStep.dependencyStepIds - .map((depId) => stepKeyById.get(depId)) - .filter((depKey): depKey is string => typeof depKey === "string" && depKey.length > 0); - const nextDependencyStepKeys = [...new Set([ - ...existingDependencyStepKeys, - milestoneStep.stepKey - ])]; - orchestratorService.updateStepDependencies({ - runId, - stepId: gatedStep.id, - dependencyStepKeys: nextDependencyStepKeys - }); - onDagMutation({ - runId, - mutation: { type: "dependency_changed", stepKey: gatedStep.stepKey, newDeps: nextDependencyStepKeys }, - timestamp: nowIso(), - source: "coordinator", - }); - gatedStepsPatched.push({ - stepKey: gatedStep.stepKey, - dependencyStepKeys: nextDependencyStepKeys - }); - } - } - - logger.info("coordinator.insert_milestone", { - milestoneStepKey: milestoneStep.stepKey, - dependsOn: normalizedDependsOn, - gatesSteps: normalizedGatesSteps - }); - return { - ok: true, - milestone: { - stepId: milestoneStep.id, - stepKey: milestoneStep.stepKey, - name: milestoneStep.title, - status: milestoneStep.status, - validationContract: milestoneContract - }, - dependsOn: normalizedDependsOn, - gatesStepsPatched: gatedStepsPatched - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.insert_milestone.error", { name, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const request_specialist = defineCoordinatorTool({ - description: - "Request a specialist worker for a specific role. Use when the current worker should not continue alone.", - inputSchema: z.object({ - role: z.string().describe("Requested specialist role (must exist in team template)"), - objective: z.string().describe("Task objective for the specialist"), - reason: z.string().describe("Why current worker cannot or should not continue alone"), - requestedByWorkerId: z.string().optional().describe("Worker ID that requested this specialist"), - name: z.string().optional().describe("Optional worker display name"), - dependsOn: z.array(z.string()).default([]).describe("Dependencies for the specialist step"), - laneId: z.string().optional().describe("Optional lane ID override"), - replacementForWorkerId: z - .string() - .optional() - .describe("Optional source worker this specialist replaces; lane + handoff package are inherited"), - replacementReason: z.string().optional().describe("Optional replacement reason"), - }), - execute: async ({ - role, - objective, - reason, - requestedByWorkerId, - name, - dependsOn, - laneId, - replacementForWorkerId, - replacementReason, - }) => { - try { - const g = graph(); - const planningInputBlockReason = getPlanningInputBlockReason(g); - if (planningInputBlockReason) { - return { ok: false, error: planningInputBlockReason }; - } - const planningQuestionPolicyBlockReason = getPlanningQuestionPolicyBlockReason(g); - if (planningQuestionPolicyBlockReason) { - return { ok: false, error: planningQuestionPolicyBlockReason }; - } - const teamRuntime = resolveTeamRuntimeConfig(g); - const normalizedRole = normalizeText(role); - const roleDef = resolveRoleDefinition(teamRuntime, normalizedRole); - if (!roleDef) { - return { ok: false, error: `Unknown specialist role '${normalizedRole || role}'.` }; - } - const normalizedReason = normalizeText(reason); - if (!normalizedReason.length) { - return { ok: false, error: "Specialist request requires a non-empty reason." }; - } - const normalizedObjective = normalizeText(objective); - if (!normalizedObjective.length) { - return { ok: false, error: "Specialist request requires a non-empty objective." }; - } - const workerName = (normalizeText(name) || `${roleDef.name}-specialist`).slice(0, 80); - const normalizedDependsOn = normalizeDependencyReferences({ - graph: g, - refs: dependsOn, - label: `Specialist '${workerName}'`, - }); - const parsedLaneId = laneId?.trim().length ? laneId.trim() : null; - const replacementSourceWorkerId = replacementForWorkerId?.trim().length ? replacementForWorkerId.trim() : null; - if (replacementSourceWorkerId && !resolveStep(g, replacementSourceWorkerId)) { - return { ok: false, error: `Replacement source worker '${replacementSourceWorkerId}' was not found.` }; - } - - // Hard cap check: refuse to spawn specialist if budget hard caps are triggered - const budgetCheck = await checkBudgetHardCaps({ - failClosedOnTelemetryError: true, - operation: "request_specialist", - }); - if (budgetCheck.blocked) { - logger.warn("coordinator.request_specialist.hard_cap_blocked", { role: normalizedRole, detail: budgetCheck.detail }); - return { - ok: false, - error: `Cannot spawn specialist: ${budgetCheck.detail}. Mission pausing.`, - hardCapTriggered: true, - hardCaps: budgetCheck.hardCaps, - }; - } - - const missionPhases = resolveMissionPhases(g); - const phaseContext = resolveConfiguredPhaseContext(g, missionPhases); - if (!phaseContext.ok) { - return { ok: false, error: phaseContext.error }; - } - const currentPhase = phaseContext.currentPhase; - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - const phaseValidation = validateDelegationPromptForCurrentPhase({ - currentPhaseKey, - currentPhase, - nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), - workerName, - roleName: roleDef.name, - prompt: normalizedObjective, - validationContract: null, - validationHeuristics: "prompt_only", - }); - if (!phaseValidation.ok) { - return { ok: false, error: phaseValidation.error }; - } - - const spawnPolicy = authorizeWorkerSpawnPolicy({ g }); - if (!spawnPolicy.ok) { - if (spawnPolicy.blockedByPhaseOrdering) { - return phaseOrderingBlockedResult( - spawnPolicy.error, - spawnPolicy.phaseTransitionRequiredKey, - spawnPolicy.phaseTransitionRequiredReason, - ); - } - return { ok: false, error: spawnPolicy.error }; - } - const specialistModelId = spawnPolicy.resolvedModelId; - const delegationIntent: DelegationIntent = replacementSourceWorkerId ? "recovery" : "specialist"; - const delegationMode: DelegationMode = replacementSourceWorkerId ? "recovery" : "bounded_parallel"; - const delegationContract = buildCoordinatorDelegationContract({ - graph: g, - workerId: `worker_${workerName.replace(/[^a-zA-Z0-9_-]/g, "_")}_${Date.now()}`, - phaseKey: currentPhaseKey, - workerName, - intent: delegationIntent, - mode: delegationMode, - scopeKind: replacementSourceWorkerId ? "worker" : "phase", - scopeKey: replacementSourceWorkerId ? `worker:${replacementSourceWorkerId}` : `phase:${currentPhaseKey || "unknown"}`, - scopeLabel: currentPhase?.name ?? roleDef.name, - metadata: { - requestedByWorkerId: requestedByWorkerId?.trim() || null, - role: roleDef.name, - }, - }); - const conflictingContract = hasConflictingDelegationContract({ - graph: g, - contract: delegationContract, - }); - if (conflictingContract) { - return { - ok: false, - error: - `Delegation scope '${delegationContract.scope.key}' is already owned by active ${conflictingContract.mode} delegation ` + - `(${conflictingContract.workerIntent}). Wait for it to finish or recover explicitly before spawning more work in the same scope.`, - }; - } - - const { workerId, step, roleName, modelId: spawnedModelId, toolProfile } = spawnWorkerStep({ - stepKey: delegationContract.activeWorkerIds[0], - name: workerName, - modelId: specialistModelId, - prompt: normalizedObjective, - dependsOn: normalizedDependsOn, - roleName: roleDef.name, - laneId: parsedLaneId, - replacementForWorkerId: replacementSourceWorkerId, - replacementReason: replacementReason?.trim() || normalizedReason, - specialistRequest: { - requestedBy: requestedByWorkerId?.trim() || null, - reason: normalizedReason - }, - delegationContract, - }); - - const specialistDescriptor = resolveModelDescriptor(spawnedModelId); - const specialistProvider = specialistDescriptor?.family === "anthropic" - ? "claude" - : specialistDescriptor?.family === "openai" - ? "codex" - : specialistDescriptor?.family ?? "unknown"; - - trackTeamMember({ - workerId, - provider: specialistProvider, - modelId: spawnedModelId, - role: roleName, - source: "ade-worker", - }); - - if (step) { - const finalizedDelegationContract = updateDelegationContract(delegationContract, { - activeWorkerIds: [workerId], - status: "active", - launchState: "waiting_on_worker", - metadata: { - ...(delegationContract.metadata ?? {}), - requestedByWorkerId: requestedByWorkerId?.trim() || null, - }, - }); - applyDelegationContractToStep({ - stepId: step.id, - contract: finalizedDelegationContract, - }); - persistDelegationContract({ - contract: finalizedDelegationContract, - stepId: step.id, - reason: "delegation_contract_created", - action: "created", - }); - onDagMutation({ - runId, - mutation: { type: "step_added", step }, - timestamp: nowIso(), - source: "coordinator", - }); - } - - orchestratorService.appendRuntimeEvent({ - runId, - stepId: step?.id ?? null, - eventType: "progress", - payload: { - type: "specialist_requested", - requestedByWorkerId: requestedByWorkerId ?? null, - role: roleDef.name, - reason: normalizedReason, - workerId - } - }); - - setTimeout(() => { - void orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_request_specialist", - }).catch((error) => { - logger.debug("coordinator.request_specialist.autopilot_schedule_failed", { - runId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, 100); - - return { - ok: true, - workerId, - role: roleName, - stepId: step?.id ?? null, - toolProfile, - replacementForWorkerId: replacementSourceWorkerId, - delegationContract: updateDelegationContract(delegationContract, { - activeWorkerIds: [workerId], - status: "active", - launchState: "waiting_on_worker", - }), - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.request_specialist.error", { role, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const stop_worker = defineCoordinatorTool({ - description: - "Stop a running worker by canceling its current attempt.", - inputSchema: z.object({ - workerId: z.string().describe("Step key (workerId) of the worker to stop"), - reason: z.string().describe("Reason for stopping the worker"), - }), - execute: async ({ workerId, reason }) => { - try { - const normalizedReason = typeof reason === "string" ? reason.trim() : ""; - if (!normalizedReason.length) { - return { - ok: false, - error: - "Cancellation reason is required. stop_worker is a destructive cancel tool; " + - "use it only when abandoning a stuck or off-track worker, not for normal completion." - }; - } - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) - return { ok: false, error: `Worker not found: ${workerId}` }; - if (stopReasonLooksLikeNormalCompletion(normalizedReason)) { - return { - ok: false, - error: - "stop_worker is cancel-only. Do not use it to end successful work or advance phases after completion. " + - "Let the worker finish and read its output instead." - }; - } - const attempt = findRunningAttempt(g, step.id); - if (!attempt) - return { - ok: false, - error: `No running attempt found for worker '${workerId}'`, - }; - const currentPhase = resolveCurrentPhaseCard(g, resolveMissionPhases(g)); - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - if (currentPhaseKey === "planning" && isPlanningExecutionStep(step) && !stopReasonIndicatesHardFailure(normalizedReason)) { - return { - ok: false, - error: - "Do not cancel the active planning worker just because the coordinator believes planning is complete. " + - "Wait for the planner to finish, or let runtime surface a concrete failure before retrying." - }; - } - const recentActivityAtMs = readRecentWorkerActivityAtMs(attempt); - const recentActivityAgeMs = recentActivityAtMs == null ? null : Date.now() - recentActivityAtMs; - if ( - !stopReasonIndicatesHardFailure(normalizedReason) - && recentActivityAgeMs != null - && recentActivityAgeMs >= 0 - && recentActivityAgeMs < RECENT_WORKER_ACTIVITY_STOP_GUARD_MS - ) { - const ageSeconds = Math.max(1, Math.ceil(recentActivityAgeMs / 1000)); - const waitSeconds = Math.max(1, Math.ceil((RECENT_WORKER_ACTIVITY_STOP_GUARD_MS - recentActivityAgeMs) / 1000)); - return { - ok: false, - status: "active_recently", - recheckAfterSeconds: waitSeconds, - error: - `Worker '${workerId}' emitted activity ${ageSeconds}s ago. ` + - "Do not cancel an actively working mission worker just because report_result has not arrived yet. " + - `Wait for a result, steer once with a concise status request, or recheck after about ${waitSeconds}s.` - }; - } - await orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "canceled", - errorClass: "canceled", - errorMessage: normalizedReason, - }); - logger.info("coordinator.stop_worker", { - workerId, - attemptId: attempt.id, - reason: normalizedReason, - }); - return { ok: true, workerId, attemptId: attempt.id }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.stop_worker.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const send_message = defineCoordinatorTool({ - description: - "Send a message to a specific running worker. The worker will see it as steering input.", - inputSchema: z.object({ - workerId: z.string().describe("Step key of the target worker"), - content: z.string().describe("Message content to send"), - }), - execute: async ({ workerId, content }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) - return { - ok: false, - delivered: false, - reason: "no_active_session" as const, - error: `Worker not found: ${workerId}` - }; - const attempt = findRunningAttempt(g, step.id); - if (!attempt) - return { - ok: false, - delivered: false, - reason: "no_active_session" as const, - error: `No running attempt found for worker '${workerId}'`, - }; - const sessionId = attempt.executorSessionId; - if (!sessionId) - return { - ok: false, - delivered: false, - reason: "no_active_session" as const, - error: `No session ID for running worker '${workerId}'`, - }; - const messageId = randomUUID(); - const delivery = await deliverToWorkerSession(sessionId, content, "urgent"); - orchestratorService.appendRuntimeEvent({ - runId, - stepId: step.id, - attemptId: attempt.id, - sessionId, - eventType: "coordinator_steering", - payload: { - message: content, - priority: "normal", - messageId, - delivered: delivery.delivered, - method: delivery.ok ? delivery.method : null, - reason: delivery.ok - ? (delivery.delivered ? null : delivery.reason) - : delivery.reason - }, - }); - logger.info("coordinator.send_message", { - workerId, - sessionId, - delivered: delivery.delivered, - method: delivery.ok ? delivery.method : null, - reason: delivery.ok - ? (delivery.delivered ? null : delivery.reason) - : delivery.reason - }); - return { - ...delivery, - workerId, - sessionId, - messageId - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.send_message.error", { workerId, error: msg }); - return { ok: false, delivered: false, reason: "delivery_failed", error: msg }; - } - }, - }); - - const message_worker = defineCoordinatorTool({ - description: - "Route a message from one worker to another through the coordinator for full visibility.", - inputSchema: z.object({ - fromWorkerId: z.string().describe("Worker step key of the sender"), - toWorkerId: z.string().describe("Worker step key of the recipient"), - content: z.string().describe("Message content"), - priority: z.enum(["low", "normal", "high", "urgent"]).default("normal"), - }), - execute: async ({ fromWorkerId, toWorkerId, content, priority }) => { - try { - const g = graph(); - const fromStep = resolveStep(g, fromWorkerId); - if (!fromStep) { - return { - ok: false, - delivered: false, - reason: "no_active_session", - error: `Sender worker not found: ${fromWorkerId}` - }; - } - const toStep = resolveStep(g, toWorkerId); - if (!toStep) { - return { - ok: false, - delivered: false, - reason: "no_active_session", - error: `Recipient worker not found: ${toWorkerId}` - }; - } - const recipientAttempt = findRunningAttempt(g, toStep.id); - if (!recipientAttempt?.executorSessionId) { - return { - ok: false, - delivered: false, - reason: "no_active_session", - error: `Recipient worker '${toWorkerId}' has no running session.` - }; - } - const deliveryPriority = priority === "high" || priority === "urgent" ? "urgent" : "normal"; - const messageId = randomUUID(); - const fromAttemptId = findRunningAttempt(g, fromStep.id)?.id ?? null; - const delivery = await deliverToWorkerSession(recipientAttempt.executorSessionId, content, deliveryPriority); - orchestratorService.appendRuntimeEvent({ - runId, - stepId: toStep.id, - attemptId: recipientAttempt.id, - sessionId: recipientAttempt.executorSessionId, - eventType: "worker_message", - payload: { - fromWorkerId, - toWorkerId, - message: content, - priority, - deliveryPriority, - messageId, - delivered: delivery.delivered, - method: delivery.ok ? delivery.method : null, - reason: delivery.ok - ? (delivery.delivered ? null : delivery.reason) - : delivery.reason, - fromAttemptId - }, - }); - orchestratorService.appendTimelineEvent({ - runId, - stepId: toStep.id, - attemptId: recipientAttempt.id, - eventType: "worker_message_routed", - reason: "message_worker", - detail: { - fromWorkerId, - toWorkerId, - priority, - deliveryPriority, - messageId, - delivered: delivery.delivered, - method: delivery.ok ? delivery.method : null, - reason: delivery.ok - ? (delivery.delivered ? null : delivery.reason) - : delivery.reason, - fromAttemptId - }, - }); - return { - ...delivery, - messageId, - fromWorkerId, - toWorkerId, - priority - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.message_worker.error", { - fromWorkerId, - toWorkerId, - error: msg - }); - return { ok: false, delivered: false, reason: "delivery_failed", error: msg }; - } - } - }); - - const broadcast = defineCoordinatorTool({ - description: - "Broadcast a message to ALL currently running workers.", - inputSchema: z.object({ - content: z.string().describe("Message content to broadcast"), - }), - execute: async ({ content }) => { - try { - const g = graph(); - const runningAttempts = g.attempts.filter((a) => a.status === "running"); - const results = await Promise.all( - runningAttempts.map(async (attempt) => { - const step = g.steps.find((candidate) => candidate.id === attempt.stepId) ?? null; - const sessionId = attempt.executorSessionId ?? null; - const messageId = randomUUID(); - if (!sessionId) { - return { - workerId: step?.stepKey ?? null, - stepId: step?.id ?? attempt.stepId, - attemptId: attempt.id, - sessionId: null, - messageId, - ok: false as const, - delivered: false as const, - reason: "no_active_session" as const, - }; - } - const delivery = await deliverToWorkerSession(sessionId, content); - return { - workerId: step?.stepKey ?? null, - stepId: step?.id ?? attempt.stepId, - attemptId: attempt.id, - sessionId, - messageId, - ...delivery - }; - }) - ); - const delivered = results.filter((result) => result.ok && result.delivered).length; - const queued = results.filter((result) => result.ok && !result.delivered).length; - const failed = results.filter((result) => !result.ok).length; - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "coordinator_broadcast", - payload: { - content, - recipientCount: results.length, - delivered, - queued, - failed, - results - }, - }); - logger.info("coordinator.broadcast", { recipients: results.length, delivered, queued, failed }); - return { - ok: true, - recipientCount: results.length, - delivered, - queued, - failed, - results - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.broadcast.error", { error: msg }); - return { ok: false, error: msg, delivered: false, reason: "delivery_failed" }; - } - }, - }); - - const get_worker_output = defineCoordinatorTool({ - description: - "Read what a completed (or running) worker has produced. Returns summary, status, files changed, and any errors.", - inputSchema: z.object({ - workerId: z.string().describe("Step key of the worker to get output for"), - }), - execute: async ({ workerId }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) - return { ok: false, error: `Worker not found: ${workerId}` }; - const stepMeta = asRecord(step.metadata) ?? {}; - const stepPhaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; - const isReadOnlyPlanningStep = - stepMeta.readOnlyExecution === true - || stepPhaseKey === "planning" - || stepType === "planning"; - // Find the latest completed attempt (succeeded or failed) - const completedAttempts = g.attempts - .filter( - (a) => - a.stepId === step.id && - (a.status === "succeeded" || a.status === "failed"), - ) - .sort((a, b) => { - const at = a.completedAt ?? a.createdAt; - const bt = b.completedAt ?? b.createdAt; - return bt.localeCompare(at); - }); - const latest = completedAttempts[0]; - // Also check for running attempt - const running = findRunningAttempt(g, step.id); - const runningCheckKey = step.id; - if (!latest && !running) - return { - ok: false, - error: `No completed or running attempt found for worker '${workerId}'`, - }; - if (latest) { - runningWorkerOutputChecks.delete(runningCheckKey); - // Extract filesChanged from the result envelope outputs - const outputs = latest.resultEnvelope?.outputs as Record<string, unknown> | null | undefined; - let filesChanged: string[] = []; - if (outputs) { - const raw = outputs.filesChanged ?? outputs.files_changed ?? []; - if (Array.isArray(raw)) { - filesChanged = raw.map((f) => String(f ?? "").trim()).filter(Boolean); - } - } - // Fallback: check worker digest table if no files from envelope - if (filesChanged.length === 0) { - try { - const digestRow = db.get<{ files_changed_json: string | null }>( - `select files_changed_json from orchestrator_worker_digests where attempt_id = ? limit 1`, - [latest.id], - ); - if (digestRow?.files_changed_json) { - const parsed = JSON.parse(digestRow.files_changed_json); - if (Array.isArray(parsed)) { - filesChanged = parsed.map((f: unknown) => String(f ?? "").trim()).filter(Boolean); - } - } - } catch { - // Non-fatal: digest lookup failure doesn't block output retrieval - } - } - return { - ok: true, - workerId, - attemptId: latest.id, - status: latest.status, - summary: latest.resultEnvelope?.summary ?? null, - success: latest.resultEnvelope?.success ?? null, - warnings: latest.resultEnvelope?.warnings ?? [], - errorMessage: latest.errorMessage ?? null, - filesChanged, - }; - } - const lastStatusReport = asRecord(stepMeta.lastStatusReport); - const lastStatusReportedAt = - typeof lastStatusReport?.reportedAt === "string" ? lastStatusReport.reportedAt.trim() : ""; - const signalFingerprint = [ - step.updatedAt ?? "", - step.startedAt ?? "", - lastStatusReportedAt, - running?.id ?? "", - running?.createdAt ?? "", - ].join("::"); - const nowMs = Date.now(); - const previousRunningCheck = runningWorkerOutputChecks.get(runningCheckKey) ?? null; - if ( - previousRunningCheck - && previousRunningCheck.signalFingerprint === signalFingerprint - && nowMs - previousRunningCheck.checkedAtMs < RUNNING_WORKER_OUTPUT_RECHECK_COOLDOWN_MS - ) { - const remainingSeconds = Math.max( - 1, - Math.ceil((RUNNING_WORKER_OUTPUT_RECHECK_COOLDOWN_MS - (nowMs - previousRunningCheck.checkedAtMs)) / 1000), - ); - return { - ok: false, - error: isReadOnlyPlanningStep - ? `Planning worker '${workerId}' is still running and has not produced any new reported output since your last check. Wait for a fresh worker event or at least ${remainingSeconds}s before calling get_worker_output again.` - : `Worker '${workerId}' is still running and has not produced any new reported output since your last check. Wait for a fresh worker event or at least ${remainingSeconds}s before calling get_worker_output again.`, - status: "running", - recheckAfterSeconds: remainingSeconds, - }; - } - runningWorkerOutputChecks.set(runningCheckKey, { - checkedAtMs: nowMs, - signalFingerprint, - }); - return { - ok: true, - workerId, - status: "running", - summary: (() => { - const nextAction = - typeof lastStatusReport?.nextAction === "string" - ? lastStatusReport.nextAction.trim() - : ""; - const details = - typeof lastStatusReport?.details === "string" - ? lastStatusReport.details.trim() - : ""; - if (details.length > 0) { - return details; - } - if (nextAction.length > 0) { - return `Worker is still running. Latest next action: ${nextAction}`; - } - if (isReadOnlyPlanningStep) { - return "Planning worker is still running. Planning/research workers can stay quiet for a while before they report back. Do not cancel solely for lack of terminal output unless the session exits or reports an explicit error."; - } - return "Worker is still running."; - })(), - progressPct: - Number.isFinite(Number(asRecord(stepMeta.lastStatusReport)?.progressPct)) - ? Number(asRecord(stepMeta.lastStatusReport)?.progressPct) - : null, - blockers: Array.isArray(asRecord(stepMeta.lastStatusReport)?.blockers) - ? (asRecord(stepMeta.lastStatusReport)?.blockers as unknown[]) - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : [], - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.get_worker_output.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const list_workers = defineCoordinatorTool({ - description: - "Get status of all workers (active, completed, failed, etc.).", - inputSchema: z.object({}), - execute: async () => { - try { - const g = graph(); - const workers = g.steps - .filter((s) => s.stepKey !== "__planner__") - .map((s) => { - const attempt = findRunningAttempt(g, s.id); - const latestCompleted = g.attempts - .filter((a) => a.stepId === s.id && a.status !== "running") - .sort((a, b) => (b.completedAt ?? b.createdAt).localeCompare(a.completedAt ?? a.createdAt))[0]; - return { - workerId: s.stepKey, - title: s.title, - status: s.status, - hasRunningAttempt: !!attempt, - lastResult: latestCompleted?.status ?? null, - retryCount: s.retryCount, - }; - }); - return { ok: true, workers, total: workers.length }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.list_workers.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const report_status = defineCoordinatorTool({ - description: - "Structured worker-to-coordinator status report with progress, blockers, confidence, and next action.", - inputSchema: z.object({ - workerId: z.string().describe("Worker step key"), - progressPct: z.number().min(0).max(100).describe("Percent complete"), - blockers: z.array(z.string()).default([]).describe("Current blockers"), - confidence: z.number().min(0).max(1).nullable().optional().describe("Confidence score from 0 to 1"), - nextAction: z.string().describe("Planned next action"), - laneId: z.string().nullable().optional().describe("Optional lane context"), - details: z.string().nullable().optional().describe("Optional extra details"), - }), - execute: async ({ workerId, progressPct, blockers, confidence, nextAction, laneId, details }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) return { ok: false, error: `Worker not found: ${workerId}` }; - const normalizedProgressPct = Math.max(0, Math.min(100, Math.round(Number(progressPct) || 0))); - const normalizedBlockers = Array.isArray(blockers) - ? blockers.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - const normalizedConfidence = Number(confidence); - const normalizedNextAction = typeof nextAction === "string" ? nextAction.trim() : ""; - const normalizedDetails = typeof details === "string" ? details : details == null ? null : String(details); - const report: WorkerStatusReport = { - workerId, - stepId: step.id, - stepKey: step.stepKey, - runId, - missionId, - progressPct: normalizedProgressPct, - blockers: normalizedBlockers, - confidence: Number.isFinite(normalizedConfidence) ? Math.max(0, Math.min(1, normalizedConfidence)) : null, - nextAction: normalizedNextAction || "continue working", - laneId: laneId ?? step.laneId, - details: normalizedDetails, - reportedAt: nowIso() - }; - const existingMeta = asRecord(step.metadata) ?? {}; - const metadata = { - ...existingMeta, - lastStatusReport: report - }; - orchestratorService.updateStepMetadata({ - runId, - stepId: step.id, - metadata, - allowTerminal: true, - }); - orchestratorService.appendRuntimeEvent({ - runId, - stepId: step.id, - eventType: "worker_status_report", - payload: report as unknown as Record<string, unknown> - }); - orchestratorService.appendTimelineEvent({ - runId, - stepId: step.id, - eventType: "worker_status_reported", - reason: "report_status", - detail: report as unknown as Record<string, unknown> - }); - orchestratorService.emitRuntimeUpdate({ - runId, - stepId: step.id, - reason: "worker_status_report" - }); - return { ok: true, report }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.report_status.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const reflection_add = defineCoordinatorTool({ - description: - "Record a structured reflection entry for mission introspection and retrospective synthesis.", - inputSchema: z.object({ - workerId: z.string().optional().describe("Optional worker step key used to infer step/attempt scope."), - stepId: z.string().optional().describe("Optional explicit step ID."), - attemptId: z.string().optional().describe("Optional explicit attempt ID."), - agentRole: z.string().optional().describe("Role submitting the reflection (defaults to coordinator)."), - phase: z.string().min(1).describe("Mission phase where this reflection was observed."), - signalType: z.enum(["wish", "frustration", "idea", "pattern", "limitation"]), - observation: z.string().min(1), - recommendation: z.string().min(1), - context: z.string().min(1), - occurredAt: z.string().optional().describe("ISO-8601 timestamp; defaults to now.") - }), - execute: async ({ workerId, stepId, attemptId, agentRole, phase, signalType, observation, recommendation, context, occurredAt }) => { - try { - const g = graph(); - const resolvedStep = workerId ? resolveStep(g, workerId) : null; - const resolvedStepId = typeof stepId === "string" && stepId.trim().length > 0 - ? stepId.trim() - : resolvedStep?.id ?? null; - const runningAttempt = resolvedStepId - ? findRunningAttempt(g, resolvedStepId) - : null; - const latestAttempt = resolvedStepId - ? findLatestCompletedAttempt(g, resolvedStepId) - : null; - const resolvedAttemptId = typeof attemptId === "string" && attemptId.trim().length > 0 - ? attemptId.trim() - : runningAttempt?.id ?? latestAttempt?.id ?? null; - - const reflection = orchestratorService.addReflection({ - missionId, - runId, - stepId: resolvedStepId, - attemptId: resolvedAttemptId, - agentRole: (agentRole ?? "coordinator").trim() || "coordinator", - phase: phase.trim(), - signalType, - observation: observation.trim(), - recommendation: recommendation.trim(), - context: context.trim(), - occurredAt: typeof occurredAt === "string" && occurredAt.trim().length > 0 ? occurredAt.trim() : nowIso() - }); - return { ok: true, reflection }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.reflection_add.error", { error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const report_result = defineCoordinatorTool({ - description: - "Structured worker completion report with outcome, artifacts, file changes, and test results.", - inputSchema: z.object({ - workerId: z.string().describe("Worker step key"), - outcome: z.enum(["succeeded", "failed", "partial"]).describe("Outcome classification"), - summary: z.string().describe("Result summary"), - plan: z.object({ - markdown: z.string().min(1).describe("Markdown plan content for planning/read-only planning steps"), - summary: z.string().optional().describe("One-line plan summary"), - title: z.string().optional().describe("Optional display title"), - format: z.literal("markdown").optional(), - artifactPath: z.string().optional().describe("Optional canonical relative plan path hint"), - }).nullable().optional(), - artifacts: z.array( - z.object({ - type: z.string(), - title: z.string(), - uri: z.string().nullable().optional(), - metadata: z.record(z.string(), z.unknown()).optional() - }) - ).default([]), - filesChanged: z.array(z.string()).default([]), - testsRun: z.object({ - command: z.string().optional(), - passed: z.number().int().optional(), - failed: z.number().int().optional(), - skipped: z.number().int().optional(), - raw: z.string().nullable().optional() - }).nullable().optional(), - laneId: z.string().nullable().optional(), - }), - execute: async ({ workerId, outcome, summary, plan, artifacts, filesChanged, testsRun, laneId }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) return { ok: false, error: `Worker not found: ${workerId}` }; - const stepMetadata = asRecord(step.metadata) ?? {}; - const stepType = typeof stepMetadata.stepType === "string" ? stepMetadata.stepType.trim().toLowerCase() : ""; - const phaseKey = typeof stepMetadata.phaseKey === "string" ? stepMetadata.phaseKey.trim().toLowerCase() : ""; - const requiresPlanPayload = - stepType === "planning" - || stepType === "analysis" - || phaseKey === "planning"; - const normalizedPlan = (() => { - const rawPlan = plan; - return rawPlan && typeof rawPlan.markdown === "string" && rawPlan.markdown.trim().length > 0 - ? { - markdown: rawPlan.markdown.trim(), - ...(typeof rawPlan.summary === "string" && rawPlan.summary.trim().length > 0 ? { summary: rawPlan.summary.trim() } : {}), - ...(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; - })(); - if (requiresPlanPayload && outcome === "succeeded" && !normalizedPlan) { - return { - ok: false, - error: "Planning workers must return a non-empty plan.markdown payload in report_result. ADE will persist the plan artifact after completion.", - }; - } - const normalizedArtifacts = Array.isArray(artifacts) ? artifacts : []; - const normalizedFilesChanged = Array.isArray(filesChanged) ? filesChanged : []; - const normalizedSummary = typeof summary === "string" ? summary.trim() : ""; - const report: WorkerResultReport = { - workerId, - stepId: step.id, - stepKey: step.stepKey, - runId, - missionId, - outcome, - summary: normalizedSummary || "Worker completed without a structured summary.", - ...(normalizedPlan ? { plan: normalizedPlan } : {}), - artifacts: normalizedArtifacts.map((artifact) => ({ - type: typeof artifact?.type === "string" ? artifact.type : "artifact", - title: typeof artifact?.title === "string" ? artifact.title : "Untitled artifact", - uri: artifact.uri ?? null, - metadata: artifact.metadata - })), - filesChanged: normalizedFilesChanged.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0), - testsRun: testsRun - ? { - ...(testsRun.command ? { command: testsRun.command } : {}), - ...(Number.isFinite(Number(testsRun.passed)) ? { passed: Number(testsRun.passed) } : {}), - ...(Number.isFinite(Number(testsRun.failed)) ? { failed: Number(testsRun.failed) } : {}), - ...(Number.isFinite(Number(testsRun.skipped)) ? { skipped: Number(testsRun.skipped) } : {}), - ...(testsRun.raw != null ? { raw: testsRun.raw } : {}) - } - : null, - laneId: laneId ?? step.laneId, - reportedAt: nowIso() - }; - const existingMeta = asRecord(step.metadata) ?? {}; - const metadata = { - ...existingMeta, - lastResultReport: report - }; - orchestratorService.updateStepMetadata({ - runId, - stepId: step.id, - metadata, - allowTerminal: true, - }); - orchestratorService.appendRuntimeEvent({ - runId, - stepId: step.id, - eventType: "worker_result_report", - payload: report as unknown as Record<string, unknown> - }); - orchestratorService.appendTimelineEvent({ - runId, - stepId: step.id, - eventType: "worker_result_reported", - reason: "report_result", - detail: report as unknown as Record<string, unknown> - }); - orchestratorService.createHandoff({ - missionId, - runId, - stepId: step.id, - handoffType: "worker_result_report", - producer: workerId, - payload: report as unknown as Record<string, unknown> - }); - orchestratorService.emitRuntimeUpdate({ - runId, - stepId: step.id, - reason: "worker_result_report" - }); - return { ok: true, report }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.report_result.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const report_validation = defineCoordinatorTool({ - description: - "Structured validator report for step/milestone/mission gates. Persists pass/fail findings and remediation guidance.", - inputSchema: z.object({ - validatorWorkerId: z.string().optional().describe("Validator worker step key"), - targetWorkerId: z.string().optional().describe("Target worker step key being validated (optional for mission-level reports)"), - stepKey: z.string().optional().describe("Alias for targetWorkerId used by recovered coordinator chat-runtime calls"), - stepId: z.string().optional().describe("Alias for targetWorkerId; may be the step key or internal step id"), - id: z.string().optional().describe("Alias for targetWorkerId; may be the step key or internal step id"), - validationId: z.string().optional().describe("Optional caller-provided validation id"), - contract: VALIDATION_CONTRACT_SCHEMA, - verdict: z.enum(["pass", "fail"]), - summary: z.string(), - findings: z - .array( - z.object({ - code: z.string(), - severity: z.enum(["low", "medium", "high"]), - message: z.string(), - remediation: z.string().optional(), - references: z.array(z.string()).optional(), - }) - ) - .default([]), - remediationInstructions: z.array(z.string()).default([]), - retriesUsed: z.number().int().min(0).optional(), - }), - execute: async ({ - validatorWorkerId, - targetWorkerId, - stepKey, - stepId, - id, - validationId, - contract, - verdict, - summary, - findings, - remediationInstructions, - retriesUsed, - }) => { - try { - const normalizedVerdict = normalizeValidationVerdict(verdict); - if (!normalizedVerdict) { - return { ok: false, error: "Validation verdict must be pass or fail." }; - } - const g = graph(); - const normalizedTargetWorkerId = [targetWorkerId, stepKey, stepId, id] - .map((candidate) => (typeof candidate === "string" ? candidate.trim() : "")) - .find((candidate) => candidate.length > 0) ?? ""; - let targetStep = normalizedTargetWorkerId ? resolveStep(g, normalizedTargetWorkerId) : null; - if (normalizedTargetWorkerId && !targetStep) { - return { ok: false, error: `Target worker not found: ${normalizedTargetWorkerId}` }; - } - let validatorStep = - typeof validatorWorkerId === "string" && validatorWorkerId.trim().length > 0 - ? resolveStep(g, validatorWorkerId.trim()) - : null; - if (validatorWorkerId && !validatorStep) { - return { ok: false, error: `Validator worker not found: ${validatorWorkerId}` }; - } - const targetStepMetaForRedirect = asRecord(targetStep?.metadata); - if (targetStep && targetStepMetaForRedirect?.autoSpawnedValidation === true) { - const redirectedTargetStepId = typeof targetStepMetaForRedirect.targetStepId === "string" - ? targetStepMetaForRedirect.targetStepId.trim() - : ""; - const redirectedTargetStep = redirectedTargetStepId.length > 0 - ? g.steps.find((candidate) => candidate.id === redirectedTargetStepId) ?? null - : null; - if (redirectedTargetStep) { - validatorStep = validatorStep ?? targetStep; - targetStep = redirectedTargetStep; - } - } - - const existingMeta = asRecord(targetStep?.metadata) ?? {}; - const resolvedContract = - parseValidationContract(contract ?? null) ?? - parseValidationContract(existingMeta.validationContract ?? null) ?? { - level: "step", - tier: "self", - required: false, - criteria: "Validation criteria unspecified.", - evidence: [], - maxRetries: 1, - }; - if (!targetStep && resolvedContract.level === "step") { - return { ok: false, error: "Step-level validation requires targetWorkerId." }; - } - const history = Array.isArray(existingMeta.validationHistory) - ? existingMeta.validationHistory.filter((entry) => asRecord(entry)) - : []; - const priorFailureCount = history.filter((entry) => asRecord(entry)?.verdict === "fail").length; - const normalizedRetriesUsed = Number.isFinite(Number(retriesUsed)) - ? Math.max(0, Math.min(50, Math.floor(Number(retriesUsed)))) - : normalizedVerdict === "fail" - ? priorFailureCount + 1 - : priorFailureCount; - const findingsInput = Array.isArray(findings) ? findings : []; - const remediationInput = Array.isArray(remediationInstructions) ? remediationInstructions : []; - const normalizedFindings = findingsInput - .map((entry) => parseValidationFinding(entry)) - .filter((entry): entry is ValidationResultReport["findings"][number] => Boolean(entry)); - const normalizedRemediation = remediationInput - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0); - const report: ValidationResultReport = { - validationId: validationId?.trim().length ? validationId.trim() : randomUUID(), - scope: { - runId, - stepId: targetStep?.id ?? null, - stepKey: targetStep?.stepKey ?? null, - missionId, - laneId: targetStep?.laneId ?? null - }, - contract: resolvedContract, - verdict: normalizedVerdict, - summary: summary.trim(), - findings: normalizedFindings, - remediationInstructions: normalizedRemediation, - retriesUsed: normalizedRetriesUsed, - createdAt: nowIso(), - validatorWorkerId: validatorStep?.stepKey ?? validatorWorkerId?.trim() ?? null - }; - const maxRetriesExceeded = normalizedVerdict === "fail" && report.retriesUsed >= resolvedContract.maxRetries; - const targetStepMeta = asRecord(targetStep?.metadata) ?? {}; - const targetIsMilestone = targetStepMeta.isMilestone === true; - const nextMetadata = { - ...existingMeta, - lastValidationReport: report, - validationHistory: [...history, report].slice(-20), - validationRetriesUsed: report.retriesUsed, - validationMaxRetries: resolvedContract.maxRetries, - validationState: normalizedVerdict, - ...(normalizedVerdict === "pass" ? { validationPassedAt: report.createdAt } : {}), - ...(maxRetriesExceeded - ? { - validationEscalationRequired: true, - validationEscalatedAt: report.createdAt - } - : {}) - }; - if (targetStep) { - orchestratorService.updateStepMetadata({ - runId, - stepId: targetStep.id, - metadata: nextMetadata, - allowTerminal: true, - }); - } - let milestoneMarkedComplete = false; - if (targetStep && targetIsMilestone && normalizedVerdict === "pass" && !TERMINAL_STEP_STATUSES.has(targetStep.status)) { - const ts = nowIso(); - db.run( - `update orchestrator_steps set status = 'succeeded', completed_at = ?, updated_at = ? where id = ? and run_id = ?`, - [ts, ts, targetStep.id, runId], - ); - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: targetStep.stepKey, newStatus: "succeeded" }, - timestamp: ts, - source: "coordinator", - }); - setTimeout(() => { - void orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "milestone_validation_passed", - }).catch((error) => { - logger.debug("coordinator.report_validation.autopilot_schedule_failed", { - runId, - stepKey: targetStep.stepKey, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, 100); - milestoneMarkedComplete = true; - } - orchestratorService.appendRuntimeEvent({ - runId, - stepId: targetStep?.id ?? null, - eventType: "validation_report", - payload: { - ...(report as unknown as Record<string, unknown>), - maxRetriesExceeded - } - }); - orchestratorService.appendTimelineEvent({ - runId, - stepId: targetStep?.id ?? null, - eventType: "validation_reported", - reason: "report_validation", - detail: { - validationId: report.validationId, - validatorWorkerId: report.validatorWorkerId ?? null, - verdict, - retriesUsed: report.retriesUsed, - maxRetries: resolvedContract.maxRetries - } - }); - orchestratorService.createHandoff({ - missionId, - runId, - stepId: targetStep?.id ?? null, - handoffType: "validation_report", - producer: report.validatorWorkerId ?? "validator", - payload: report as unknown as Record<string, unknown> - }); - orchestratorService.emitRuntimeUpdate({ - runId, - stepId: targetStep?.id ?? validatorStep?.id ?? null, - reason: "validation_report" - }); - if (maxRetriesExceeded) { - orchestratorService.appendTimelineEvent({ - runId, - stepId: targetStep?.id ?? null, - eventType: "validation_escalated", - reason: "validation_retry_exhausted", - detail: { - validationId: report.validationId, - retriesUsed: report.retriesUsed, - maxRetries: resolvedContract.maxRetries - } - }); - } - let escalationInterventionId: string | null = null; - if (maxRetriesExceeded && resolvedContract.required) { - const findingSummary = report.findings.slice(0, 3).map((entry) => `${entry.code}: ${entry.message}`).join("; "); - const escalationQuestion = targetStep - ? `Validation retries exhausted for "${targetStep.stepKey}". Should we continue with a workaround, re-scope, or pause this mission?` - : "Validation retries exhausted for a required contract. Should we continue with a workaround, re-scope, or pause this mission?"; - const escalationContext = [ - `Validation contract tier: ${resolvedContract.tier}`, - `Validation level: ${resolvedContract.level}`, - `Retries used: ${report.retriesUsed}/${resolvedContract.maxRetries}`, - `Summary: ${report.summary}`, - findingSummary.length > 0 ? `Findings: ${findingSummary}` : null, - ] - .filter((entry): entry is string => Boolean(entry)) - .join("\n"); - const escalation = openHumanIntervention({ - question: escalationQuestion, - context: escalationContext, - urgency: "high", - source: "request_user_input", - canProceedWithoutAnswer: false, - }); - if (escalation.ok) { - escalationInterventionId = escalation.interventionId; - } - } - return { - ok: true, - report, - maxRetriesExceeded, - milestoneMarkedComplete, - interventionId: escalationInterventionId, - recommendedAction: maxRetriesExceeded - ? "escalate_human_or_replan" - : verdict === "fail" - ? "rework_same_lane" - : targetStep?.status === "failed" - ? "mark_step_complete" - : "proceed" - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.report_validation.error", { targetWorkerId: targetWorkerId ?? null, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const read_mission_status = defineCoordinatorTool({ - description: - "Read current mission state including active/completed steps, worker status reports, and staleness signals.", - inputSchema: z.object({}), - execute: async () => { - try { - const g = graph(); - const relevantSteps = filterExecutionSteps(g.steps); - const activeSteps = relevantSteps.filter((step) => !TERMINAL_STEP_STATUSES.has(step.status)); - const completedSteps = relevantSteps.filter((step) => TERMINAL_STEP_STATUSES.has(step.status)); - const openObligations = relevantSteps.flatMap((step) => { - const stepMeta = asRecord(step.metadata) ?? {}; - const lastStatusReport = asRecord(stepMeta.lastStatusReport); - const lastResultReport = asRecord(stepMeta.lastResultReport); - const lastValidationReport = asRecord(stepMeta.lastValidationReport); - const validationContract = parseValidationContract(stepMeta.validationContract ?? null); - const runningAttempt = g.attempts.some((attempt) => attempt.stepId === step.id && attempt.status === "running"); - const obligations: Array<{ - code: string; - stepKey: string; - stepId: string; - laneId: string | null; - severity: "low" | "medium" | "high"; - summary: string; - }> = []; - if (runningAttempt && !lastStatusReport) { - obligations.push({ - code: "missing_status_report", - stepKey: step.stepKey, - stepId: step.id, - laneId: step.laneId, - severity: "medium", - summary: "Running worker has not submitted a structured status report." - }); - } - if ( - (step.status === "succeeded" || step.status === "failed") && - stepMeta.spawnedByCoordinator === true && - !isAutoSpawnedValidationProofStep(step) && - !lastResultReport - ) { - obligations.push({ - code: "missing_result_report", - stepKey: step.stepKey, - stepId: step.id, - laneId: step.laneId, - severity: "high", - summary: "Completed worker has no structured result report." - }); - } - if (validationContract?.required && !isAutoSpawnedValidationProofStep(step) && !lastValidationReport) { - obligations.push({ - code: "missing_validation_report", - stepKey: step.stepKey, - stepId: step.id, - laneId: step.laneId, - severity: "high", - summary: "Required validation contract has no validator report." - }); - } - if ( - validationContract && - lastValidationReport?.verdict === "fail" && - stepMeta.validationEscalationRequired !== true - ) { - obligations.push({ - code: "validation_rework_pending", - stepKey: step.stepKey, - stepId: step.id, - laneId: step.laneId, - severity: "high", - summary: "Validation failed and requires coordinator rework routing." - }); - } - return obligations; - }); - const workerReports = relevantSteps - .map((step) => ({ - workerId: step.stepKey, - stepId: step.id, - status: step.status, - laneId: step.laneId, - lastStatusReport: asRecord(step.metadata)?.lastStatusReport ?? null, - lastResultReport: asRecord(step.metadata)?.lastResultReport ?? null, - lastValidationReport: asRecord(step.metadata)?.lastValidationReport ?? null, - validationContract: parseValidationContract(asRecord(step.metadata)?.validationContract ?? null) - })); - return { - ok: true, - missionId, - runId, - runStatus: g.run.status, - counts: { - total: relevantSteps.length, - active: activeSteps.length, - completed: completedSteps.length, - runningAttempts: g.attempts.filter((attempt) => attempt.status === "running").length - }, - activeSteps: activeSteps.map((step) => ({ - stepId: step.id, - stepKey: step.stepKey, - title: step.title, - status: step.status, - laneId: step.laneId - })), - completedSteps: completedSteps.map((step) => ({ - stepId: step.id, - stepKey: step.stepKey, - status: step.status - })), - workerReports, - openObligations, - stalenessSignals: buildStalenessSignals(g) - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.read_mission_status.error", { error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const read_mission_state = defineCoordinatorTool({ - description: - "Read the durable mission state document from disk. Use this to refresh your understanding before major decisions.", - inputSchema: z.object({}), - execute: async () => { - try { - const state = await readMissionStateDocument({ - projectRoot, - runId, - }); - return { - ok: true, - exists: Boolean(state), - state, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.read_mission_state.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const update_mission_state = defineCoordinatorTool({ - description: - "Write a partial update into the durable mission state document (merge semantics). Use after significant decisions and updates.", - inputSchema: z - .object({ - addStepOutcome: STEP_OUTCOME_SCHEMA.optional(), - updateStepOutcome: z - .object({ - stepKey: z.string(), - updates: STEP_OUTCOME_PARTIAL_SCHEMA, - }) - .optional(), - addDecision: DECISION_SCHEMA.optional(), - addIssue: ISSUE_SCHEMA.optional(), - resolveIssue: z.object({ id: z.string(), resolution: z.string() }).optional(), - updateProgress: PROGRESS_PARTIAL_SCHEMA.optional(), - }) - .refine( - (value) => - Boolean( - value.addStepOutcome || - value.updateStepOutcome || - value.addDecision || - value.addIssue || - value.resolveIssue || - value.updateProgress - ), - { message: "At least one mission state update field is required." } - ), - execute: async ({ addStepOutcome, updateStepOutcome, addDecision, addIssue, resolveIssue, updateProgress }) => { - try { - const graphSnapshot = graph(); - const patch: MissionStateDocumentPatch = { - updateProgress: { - ...buildMissionStateProgress(graphSnapshot), - ...(updateProgress ?? {}), - }, - }; - if (addStepOutcome) patch.addStepOutcome = addStepOutcome as MissionStateStepOutcome; - if (updateStepOutcome) { - patch.updateStepOutcome = { - stepKey: updateStepOutcome.stepKey, - updates: updateStepOutcome.updates as MissionStateStepOutcomePartial, - }; - } - if (addDecision) patch.addDecision = addDecision as MissionStateDecision; - if (addIssue) patch.addIssue = addIssue as MissionStateIssue; - if (resolveIssue) patch.resolveIssue = resolveIssue; - - const nextState = await updateMissionStateDocument({ - projectRoot, - missionId, - runId, - goal: resolveMissionGoal(), - patch, - initialProgress: buildMissionStateProgress(graphSnapshot), - }); - return { - ok: true, - state: nextState, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.update_mission_state.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const revise_plan = defineCoordinatorTool({ - description: - "Revise mission plan by partially or fully replacing steps. Replaced steps are marked superseded (not deleted).", - inputSchema: z.object({ - mode: z.enum(["partial", "full"]).default("partial"), - replaceStepKeys: z.array(z.string()).default([]), - replacementMap: z - .array( - z.object({ - oldStepKey: z.string(), - newStepKey: z.string().nullable().optional(), - }) - ) - .default([]) - .describe("Explicit supersede mapping. Runtime will not infer replacement targets when this is provided."), - dependencyPatches: z - .array( - z.object({ - stepKey: z.string(), - dependencyStepKeys: z.array(z.string()), - }) - ) - .default([]) - .describe("Explicit dependency updates to apply after replanning."), - reason: z.string().describe("Why replanning is needed"), - newSteps: z.array( - z.object({ - key: z.string(), - title: z.string(), - description: z.string(), - dependsOn: z.array(z.string()).default([]), - modelId: z.string().optional(), - role: z.string().optional(), - laneId: z.string().nullable().optional(), - replaces: z.array(z.string()).default([]), - validationContract: VALIDATION_CONTRACT_SCHEMA, - }) - ).default([]), - }), - execute: async ({ mode, replaceStepKeys, replacementMap, dependencyPatches, reason, newSteps }) => { - try { - const initialGraph = graph(); - const normalizedReason = normalizeText(reason) || "Plan revision requested."; - const normalizedNewSteps = Array.isArray(newSteps) ? newSteps : []; - const normalizedReplacementMap = Array.isArray(replacementMap) ? replacementMap : []; - const normalizedDependencyPatches = Array.isArray(dependencyPatches) ? dependencyPatches : []; - const normalizedReplaceStepKeys = dedupeKeys(replaceStepKeys); - const replacementTargets = (() => { - if (mode === "full") { - return initialGraph.steps - .filter((step) => !TERMINAL_STEP_STATUSES.has(step.status)) - .map((step) => step.stepKey); - } - return [ - ...normalizedReplaceStepKeys, - ...normalizedReplacementMap - .map((entry) => normalizeText((entry as { oldStepKey?: unknown }).oldStepKey)) - .filter((entry) => entry.length > 0), - ...normalizedNewSteps.flatMap((entry) => dedupeKeys((entry as { replaces?: unknown[] }).replaces)) - ].filter((entry) => entry.length > 0); - })(); - const uniqueTargets = [...new Set(replacementTargets)]; - if (!uniqueTargets.length && normalizedNewSteps.length === 0 && normalizedDependencyPatches.length === 0) { - return { ok: false, error: "No steps selected for replacement." }; - } - - // Hard cap check: refuse to spawn replacement workers if budget hard caps are triggered - if (normalizedNewSteps.length > 0) { - const budgetCheck = await checkBudgetHardCaps({ - failClosedOnTelemetryError: true, - operation: "revise_plan", - }); - if (budgetCheck.blocked) { - logger.warn("coordinator.revise_plan.hard_cap_blocked", { detail: budgetCheck.detail }); - return { - ok: false, - error: `Cannot revise plan (spawning blocked): ${budgetCheck.detail}. Mission pausing.`, - hardCapTriggered: true, - hardCaps: budgetCheck.hardCaps, - }; - } - } - - const teamRuntime = resolveTeamRuntimeConfig(initialGraph); - const stepByKey = new Map(initialGraph.steps.map((step) => [step.stepKey, step] as const)); - const existingStepKeys = new Set(initialGraph.steps.map((step) => step.stepKey)); - const requestNewStepKeys = new Set<string>(); - const knownStepKeysAfterCreation = new Set(existingStepKeys); - const parsedNewSteps: Array<{ - key: string; - title: string; - description: string; - modelId: string; - roleName: string | null; - laneId: string | null; - dependsOn: string[]; - replaces: string[]; - parsedContract: ValidationContract | null; - replacementSourceStep: OrchestratorStep | null; - }> = []; - const parsedDependencyPatches: Array<{ stepKey: string; dependencyStepKeys: string[] }> = []; - const replacementPlanByOldStepKey = new Map<string, string | null>(); - - for (const targetKey of uniqueTargets) { - if (!stepByKey.has(targetKey)) { - return { ok: false, error: `Replacement target step '${targetKey}' was not found.` }; - } - } - - for (const entry of normalizedNewSteps) { - const normalizedKey = normalizeText(entry.key); - if (!normalizedKey.length) { - return { ok: false, error: "Each new plan step requires a non-empty key." }; - } - const normalizedTitle = normalizeText(entry.title); - if (!normalizedTitle.length) { - return { ok: false, error: `New step '${normalizedKey}' requires a non-empty title.` }; - } - const normalizedDescription = normalizeText(entry.description); - if (!normalizedDescription.length) { - return { ok: false, error: `New step '${normalizedKey}' requires a non-empty description.` }; - } - if (requestNewStepKeys.has(normalizedKey)) { - return { ok: false, error: `Duplicate new step key '${normalizedKey}' in revise_plan request.` }; - } - const replaces = dedupeKeys(entry.replaces); - const unknownReplacements = replaces.filter((candidate) => !stepByKey.has(candidate)); - if (unknownReplacements.length > 0) { - return { - ok: false, - error: `Step '${normalizedKey}' replaces unknown step keys: ${unknownReplacements.join(", ")}` - }; - } - if ( - existingStepKeys.has(normalizedKey) && - !uniqueTargets.includes(normalizedKey) && - !replaces.includes(normalizedKey) - ) { - return { ok: false, error: `Step key '${normalizedKey}' already exists.` }; - } - const normalizedRole = normalizeText(entry.role); - const roleDef = normalizedRole.length > 0 ? resolveRoleDefinition(teamRuntime, normalizedRole) : null; - if (normalizedRole.length > 0 && !roleDef) { - return { ok: false, error: `Unknown role '${normalizedRole}' in active team template.` }; - } - const parsedContract = parseValidationContract(entry.validationContract ?? null); - if (entry.validationContract && !parsedContract) { - return { ok: false, error: `Invalid validation contract for step '${entry.key}'.` }; - } - const dependsOn = normalizeDependencyReferences({ - graph: initialGraph, - refs: entry.dependsOn, - label: `Revised step '${normalizedKey}'`, - additionalKnownKeys: knownStepKeysAfterCreation, - }); - const replacementSourceStep = replaces.length > 0 ? (stepByKey.get(replaces[0]) ?? null) : null; - const modelOverride = normalizeText(entry.modelId); - const spawnPolicy = authorizeWorkerSpawnPolicy({ - g: initialGraph, - requestedModelId: modelOverride, - }); - if (!spawnPolicy.ok) { - if (spawnPolicy.blockedByPhaseOrdering) { - return phaseOrderingBlockedResult( - spawnPolicy.error, - spawnPolicy.phaseTransitionRequiredKey, - spawnPolicy.phaseTransitionRequiredReason, - ); - } - return { ok: false, error: spawnPolicy.error }; - } - parsedNewSteps.push({ - key: normalizedKey, - title: normalizedTitle, - description: normalizedDescription, - modelId: spawnPolicy.resolvedModelId, - roleName: normalizedRole.length > 0 ? normalizedRole : null, - laneId: entry.laneId ?? replacementSourceStep?.laneId ?? null, - dependsOn, - replaces, - parsedContract, - replacementSourceStep, - }); - requestNewStepKeys.add(normalizedKey); - knownStepKeysAfterCreation.add(normalizedKey); - for (const replacedKey of replaces) { - replacementPlanByOldStepKey.set(replacedKey, normalizedKey); - } - } - - for (const planned of parsedNewSteps) { - const unknownDeps = planned.dependsOn.filter((depKey) => !knownStepKeysAfterCreation.has(depKey)); - if (unknownDeps.length > 0) { - return { - ok: false, - error: `New step '${planned.key}' references unknown dependency keys: ${unknownDeps.join(", ")}` - }; - } - } - - for (const entry of normalizedReplacementMap) { - const oldStepKey = normalizeText(entry.oldStepKey); - if (!oldStepKey.length) continue; - if (!stepByKey.has(oldStepKey)) { - return { ok: false, error: `replacementMap references unknown oldStepKey '${oldStepKey}'.` }; - } - const newStepKey = normalizeText(entry.newStepKey); - if (!newStepKey.length) { - replacementPlanByOldStepKey.set(oldStepKey, null); - continue; - } - if (!knownStepKeysAfterCreation.has(newStepKey)) { - return { ok: false, error: `replacementMap references unknown newStepKey '${newStepKey}'.` }; - } - replacementPlanByOldStepKey.set(oldStepKey, newStepKey); - } - - for (const patch of normalizedDependencyPatches) { - const stepKey = normalizeText(patch.stepKey); - if (!stepKey.length) continue; - if (!knownStepKeysAfterCreation.has(stepKey)) { - return { ok: false, error: `dependencyPatches references unknown step '${stepKey}'.` }; - } - const nextDeps = normalizeDependencyReferences({ - graph: initialGraph, - refs: patch.dependencyStepKeys, - label: `Dependency patch '${stepKey}'`, - additionalKnownKeys: knownStepKeysAfterCreation, - }); - const unknownDeps = nextDeps.filter((depKey) => !knownStepKeysAfterCreation.has(depKey)); - if (unknownDeps.length > 0) { - return { - ok: false, - error: `dependencyPatches for '${stepKey}' references unknown dependency keys: ${unknownDeps.join(", ")}` - }; - } - parsedDependencyPatches.push({ stepKey, dependencyStepKeys: nextDeps }); - } - - const createdSteps: OrchestratorStep[] = []; - const createdStepByKey = new Map<string, OrchestratorStep>(); - for (const plannedStep of parsedNewSteps) { - const spawnResult = spawnWorkerStep({ - stepKey: plannedStep.key, - name: plannedStep.title, - modelId: plannedStep.modelId, - prompt: plannedStep.description, - dependsOn: plannedStep.dependsOn, - roleName: plannedStep.roleName, - laneId: plannedStep.laneId, - replacementForWorkerId: plannedStep.replacementSourceStep?.stepKey ?? null, - replacementReason: `Plan revised: ${normalizedReason}`, - validationContract: plannedStep.parsedContract - }); - if (spawnResult.step) { - createdSteps.push(spawnResult.step); - createdStepByKey.set(spawnResult.step.stepKey, spawnResult.step); - onDagMutation({ - runId, - mutation: { type: "step_added", step: spawnResult.step }, - timestamp: nowIso(), - source: "coordinator", - }); - } - } - - const replacementByOldStepKey = new Map<string, OrchestratorStep | null>(); - for (const [oldStepKey, newStepKey] of replacementPlanByOldStepKey.entries()) { - if (!newStepKey?.length) { - replacementByOldStepKey.set(oldStepKey, null); - continue; - } - const mappedStep = createdStepByKey.get(newStepKey) ?? resolveStep(graph(), newStepKey); - if (!mappedStep) { - throw new Error(`Replacement step '${newStepKey}' is unavailable while applying revise_plan.`); - } - replacementByOldStepKey.set(oldStepKey, mappedStep); - } - - const superseded: Array<{ stepKey: string; replacementStepKey: string | null }> = []; - for (const targetKey of uniqueTargets) { - const g = graph(); - const targetStep = resolveStep(g, targetKey); - if (!targetStep) continue; - if (TERMINAL_STEP_STATUSES.has(targetStep.status)) continue; - const replacement = replacementByOldStepKey.get(targetKey) ?? null; - const next = await orchestratorService.supersedeStep({ - runId, - stepId: targetStep.id, - replacementStepId: replacement?.id ?? null, - replacementStepKey: replacement?.stepKey ?? null, - reason: normalizedReason - }); - superseded.push({ - stepKey: targetStep.stepKey, - replacementStepKey: replacement?.stepKey ?? null - }); - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: next.stepKey, newStatus: "superseded" }, - timestamp: nowIso(), - source: "coordinator", - }); - } - - const postSupersede = graph(); - for (const patch of parsedDependencyPatches) { - const targetStep = resolveStep(postSupersede, patch.stepKey); - if (!targetStep) { - throw new Error(`Dependency patch target step '${patch.stepKey}' is unavailable while applying revise_plan.`); - } - const nextDeps = patch.dependencyStepKeys; - orchestratorService.updateStepDependencies({ - runId, - stepId: targetStep.id, - dependencyStepKeys: nextDeps - }); - onDagMutation({ - runId, - mutation: { type: "dependency_changed", stepKey: targetStep.stepKey, newDeps: nextDeps }, - timestamp: nowIso(), - source: "coordinator", - }); - } - - const refreshed = graph(); - const supersededIds = new Set(refreshed.steps.filter((step) => step.status === "superseded").map((step) => step.id)); - const danglingDependencySteps = refreshed.steps - .filter((step) => !TERMINAL_STEP_STATUSES.has(step.status)) - .filter((step) => step.dependencyStepIds.some((depId) => supersededIds.has(depId))) - .map((step) => step.stepKey); - const warnings: string[] = []; - if (danglingDependencySteps.length > 0) { - warnings.push( - `Steps depend on superseded predecessors and require explicit dependency patching: ${[...new Set(danglingDependencySteps)].join(", ")}` - ); - } - - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "plan_revised", - payload: { - mode, - reason: normalizedReason, - replacedStepKeys: uniqueTargets, - newStepKeys: createdSteps.map((step) => step.stepKey), - dependencyPatchesApplied: normalizedDependencyPatches.length, - warnings - } - }); - orchestratorService.appendTimelineEvent({ - runId, - eventType: "plan_revised", - reason: "revise_plan", - detail: { - mode, - reason: normalizedReason, - superseded, - newStepKeys: createdSteps.map((step) => step.stepKey), - dependencyPatchesApplied: normalizedDependencyPatches.length, - warnings - } - }); - - setTimeout(() => { - void orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_revise_plan", - }).catch((error) => { - logger.debug("coordinator.revise_plan.autopilot_schedule_failed", { - runId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, 100); - - return { - ok: true, - mode, - reason: normalizedReason, - superseded, - newStepKeys: createdSteps.map((step) => step.stepKey), - dependencyPatchesApplied: normalizedDependencyPatches.length, - warnings - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.revise_plan.error", { error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const update_tool_profiles = defineCoordinatorTool({ - description: - "Update role-bound tool profiles during a run. Useful when conditions change mid-mission.", - inputSchema: z.object({ - role: z.string().describe("Role name to update"), - allowedTools: z.array(z.string()).min(1), - blockedTools: z.array(z.string()).optional(), - notes: z.string().optional(), - }), - execute: async ({ role, allowedTools, blockedTools, notes }) => { - try { - const runRow = db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - if (!runRow) return { ok: false, error: `Run not found: ${runId}` }; - const metadata = runRow.metadata_json ? (JSON.parse(runRow.metadata_json) as Record<string, unknown>) : {}; - const teamRuntime = asRecord(metadata.teamRuntime) ?? {}; - const currentProfiles = asRecord(teamRuntime.toolProfiles) ?? {}; - const normalizedRole = normalizeText(role).toLowerCase(); - if (!normalizedRole.length) { - return { ok: false, error: "Role is required." }; - } - currentProfiles[normalizedRole] = { - allowedTools: dedupeKeys(allowedTools), - ...(Array.isArray(blockedTools) && blockedTools.length > 0 - ? { blockedTools: dedupeKeys(blockedTools) } - : {}), - ...(normalizeText(notes).length > 0 ? { notes: normalizeText(notes) } : {}) - }; - metadata.teamRuntime = { - ...teamRuntime, - enabled: teamRuntime.enabled === true, - toolProfiles: currentProfiles - }; - db.run( - `update orchestrator_runs set metadata_json = ?, updated_at = ? where id = ?`, - [JSON.stringify(metadata), nowIso(), runId] - ); - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "tool_profiles_updated", - payload: { - role: normalizedRole, - allowedTools: currentProfiles[normalizedRole] - } - }); - orchestratorService.appendTimelineEvent({ - runId, - eventType: "tool_profiles_updated", - reason: "update_tool_profiles", - detail: { - role: normalizedRole, - allowedToolCount: (currentProfiles[normalizedRole] as Record<string, unknown>)?.allowedTools instanceof Array - ? ((currentProfiles[normalizedRole] as Record<string, unknown>).allowedTools as unknown[]).length - : 0 - } - }); - return { ok: true, role: normalizedRole }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.update_tool_profiles.error", { error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const transfer_lane = defineCoordinatorTool({ - description: - "Transfer a step to a different lane. Lane transfers are explicit coordinator actions and fully logged.", - inputSchema: z.object({ - workerId: z.string().describe("Worker step key to transfer"), - laneId: z.string().nullable().describe("Destination lane id (or null to unassign)"), - reason: z.string().describe("Why the lane transfer is needed"), - }), - execute: async ({ workerId, laneId, reason }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) return { ok: false, error: `Worker not found: ${workerId}` }; - const updated = orchestratorService.transferStepLane({ - runId, - stepId: step.id, - laneId, - reason, - transferredBy: "coordinator" - }); - orchestratorService.appendRuntimeEvent({ - runId, - stepId: step.id, - eventType: "lane_transfer", - payload: { - workerId, - fromLaneId: step.laneId, - toLaneId: laneId, - reason - } - }); - return { - ok: true, - workerId, - fromLaneId: step.laneId, - toLaneId: updated.laneId - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.transfer_lane.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const provision_lane = defineCoordinatorTool({ - description: - "Create a new lane (git worktree) branching from the base lane. Use this when you need to isolate parallel workstreams or when tasks might touch overlapping files.", - inputSchema: z.object({ - name: z.string().describe("Human-readable name for the lane (e.g. 'auth-backend', 'ui-refactor')"), - description: z.string().optional().describe("Optional description of what this lane is for"), - }), - execute: async ({ name, description }) => { - if (!deps.provisionLane) { - return { ok: false, error: "Lane provisioning is not available (no lane service configured)." }; - } - try { - const result = await deps.provisionLane(name, description); - return { ok: true, laneId: result.laneId, name: result.name }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.provision_lane.error", { name, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - // ─── Task Management ────────────────────────────────────────── - - const set_current_phase = defineCoordinatorTool({ - description: - "Set the active mission phase. Use after planning is complete to transition into implementation/testing/validation phases.", - inputSchema: z.object({ - phaseKey: z.string().describe("Phase key to activate, for example: planning, development, testing, validation"), - reason: z.string().optional().describe("Optional reason for the phase transition"), - }), - execute: async ({ phaseKey, reason }) => { - try { - const g = graph(); - const missionPhases = [...resolveMissionPhases(g)].sort((a, b) => a.position - b.position); - if (missionPhases.length === 0) { - return { ok: false, error: "Mission phase configuration is unavailable." }; - } - const normalizedKey = typeof phaseKey === "string" ? phaseKey.trim() : ""; - if (!normalizedKey.length) { - return { ok: false, error: "phaseKey is required." }; - } - const targetPhase = missionPhases.find((phase) => phase.phaseKey === normalizedKey); - if (!targetPhase) { - return { - ok: false, - error: `Unknown phase '${normalizedKey}'. Available phases: ${missionPhases.map((phase) => phase.phaseKey).join(", ")}` - }; - } - - const currentPhase = resolveCurrentPhaseCard(g, missionPhases); - if (currentPhase?.phaseKey === targetPhase.phaseKey) { - return { - ok: true, - changed: false, - currentPhaseKey: currentPhase.phaseKey, - currentPhaseName: currentPhase.name, - }; - } - - const hasSuccessfulCompletion = (phase: PhaseCard): boolean => - phaseHasSuccessfulCompletion(phase, (p) => getStepsForPhase(g, p)); - const hasValidatedSuccessfulCompletion = (phase: PhaseCard): boolean => - phaseHasValidatedSuccessfulCompletion(phase, (p) => getStepsForPhase(g, p)); - - const targetIndex = missionPhases.findIndex((phase) => phase.phaseKey === targetPhase.phaseKey); - if (targetIndex < 0) { - return { ok: false, error: `Could not resolve target phase index for '${targetPhase.phaseKey}'.` }; - } - - if ( - currentPhase?.phaseKey === "planning" - && targetPhase.phaseKey !== "planning" - && !hasSuccessfulCompletion(currentPhase) - ) { - return { - ok: false, - error: "Planning phase has not completed yet. Wait for a planning worker to succeed before transitioning." - }; - } - - if ( - currentPhase - && targetPhase.phaseKey !== currentPhase.phaseKey - && phaseRequiresPlanningQuestionBeforeExit({ - phase: currentPhase, - missionPrompt: getMissionPromptForPlanningPolicy(), - }) - ) { - const mission = missionService.get(missionId); - const hasAnsweredPlanningQuestion = hasResolvedPlanningQuestionIntervention( - mission?.interventions ?? [], - { runId }, - ); - if (!hasAnsweredPlanningQuestion) { - return { - ok: false, - error: `Planning requires a resolved blocking question before entering "${targetPhase.name}". Retry the planning worker and have it open an ADE chat question, or answer the pending planning intervention if ADE opened one.`, - }; - } - } - - const explicitMustFollow = (targetPhase.orderingConstraints.mustFollow ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - for (let i = 0; i < targetIndex; i += 1) { - const earlier = missionPhases[i]!; - const mustComplete = earlier.orderingConstraints.mustBeFirst || (explicitMustFollow.length === 0 && earlier.validationGate.required); - if (mustComplete && !hasValidatedSuccessfulCompletion(earlier)) { - return { - ok: false, - error: `Cannot enter phase '${targetPhase.name}' before '${earlier.name}' has succeeded.` - }; - } - } - - for (const predecessorKey of explicitMustFollow) { - const predecessor = findPhaseByRef(missionPhases, predecessorKey); - if (predecessor && !hasValidatedSuccessfulCompletion(predecessor)) { - return { - ok: false, - error: `Cannot enter phase '${targetPhase.name}' until '${predecessor.name}' succeeds (mustFollow).` - }; - } - } - - 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) { - const mission = missionService.get(missionId); - const approvalInterventions = (mission?.interventions ?? []).filter((entry: any) => { - if (entry.interventionType !== "phase_approval") return false; - const meta = asRecord(entry.metadata); - const phase = typeof meta?.phaseKey === "string" ? meta.phaseKey.trim() : ""; - const targetPhaseKey = typeof meta?.targetPhaseKey === "string" ? meta.targetPhaseKey.trim() : ""; - const approvalRunId = typeof meta?.runId === "string" ? meta.runId.trim() : ""; - if (approvalRunId && approvalRunId !== runId) return false; - if (phase !== currentPhase.phaseKey && phase !== "") return false; - if (targetPhaseKey && targetPhaseKey !== targetPhase.phaseKey) return false; - return true; - }); - const hasResolvedApproval = approvalInterventions.some((entry: any) => entry.status === "resolved"); - if (!hasResolvedApproval) { - // Create a blocking phase_approval intervention if none exists yet - const hasOpenApproval = approvalInterventions.some((entry: any) => entry.status === "open"); - if (!hasOpenApproval) { - missionService.addIntervention({ - missionId, - interventionType: "phase_approval", - title: `Approve transition from "${currentPhase.name}" phase`, - body: `The "${currentPhase.name}" phase requires manual approval before the mission can proceed to "${targetPhase.name}". Please review the phase output and approve to continue.`, - requestedAction: `Approve the "${currentPhase.name}" output to proceed to "${targetPhase.name}".`, - pauseMission: true, - metadata: { - runId, - phaseKey: currentPhase.phaseKey, - phaseName: currentPhase.name, - targetPhaseKey: targetPhase.phaseKey, - targetPhaseName: targetPhase.name, - source: "phase_approval_gate", - }, - }); - } - return { - ok: false, - error: `Phase "${currentPhase.name}" requires manual approval before transitioning to "${targetPhase.name}". A phase_approval intervention has been created. Wait for the user to approve.`, - pendingApproval: true, - phaseKey: currentPhase.phaseKey, - }; - } - } - - const now = nowIso(); - const runRow = db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [runId] - ); - const metadata = (() => { - try { - const parsed = runRow?.metadata_json ? JSON.parse(runRow.metadata_json) : {}; - return asRecord(parsed) ?? {}; - } catch { - return {} as Record<string, unknown>; - } - })(); - const phaseRuntimeSource = asRecord(metadata.phaseRuntime); - const phaseRuntime: Record<string, unknown> = phaseRuntimeSource ? { ...phaseRuntimeSource } : {}; - const previousPhaseKey = typeof phaseRuntime.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey : null; - const previousPhaseName = typeof phaseRuntime.currentPhaseName === "string" ? phaseRuntime.currentPhaseName : null; - const transitions = Array.isArray(phaseRuntime.transitions) ? [...phaseRuntime.transitions] : []; - const transitionReason = typeof reason === "string" && reason.trim().length > 0 - ? reason.trim() - : "coordinator_set_current_phase"; - transitions.unshift({ - fromPhaseKey: previousPhaseKey, - fromPhaseName: previousPhaseName, - toPhaseKey: targetPhase.phaseKey, - toPhaseName: targetPhase.name, - at: now, - reason: transitionReason - }); - const existingPhaseBudgets = asRecord(phaseRuntime.phaseBudgets) ?? {}; - const targetPhaseBudget = asRecord(existingPhaseBudgets[targetPhase.phaseKey]); - phaseRuntime.transitions = transitions.slice(0, 64); - phaseRuntime.currentPhaseKey = targetPhase.phaseKey; - phaseRuntime.currentPhaseName = targetPhase.name; - phaseRuntime.currentPhaseModel = targetPhase.model; - phaseRuntime.currentPhaseInstructions = targetPhase.instructions; - phaseRuntime.currentPhaseValidation = targetPhase.validationGate; - phaseRuntime.currentPhaseBudget = targetPhase.budget ?? {}; - phaseRuntime.transitionedAt = now; - phaseRuntime.phaseBudgets = { - ...existingPhaseBudgets, - [targetPhase.phaseKey]: { - enteredAt: typeof targetPhaseBudget?.enteredAt === "string" && targetPhaseBudget.enteredAt.trim().length > 0 - ? targetPhaseBudget.enteredAt - : now, - usedTokens: Number.isFinite(Number(targetPhaseBudget?.usedTokens)) - ? Number(targetPhaseBudget?.usedTokens) - : 0, - usedCostUsd: Number.isFinite(Number(targetPhaseBudget?.usedCostUsd)) - ? Number(targetPhaseBudget?.usedCostUsd) - : 0 - } - }; - metadata.phaseRuntime = phaseRuntime; - - db.run( - `update orchestrator_runs set metadata_json = ?, updated_at = ? where id = ?`, - [JSON.stringify(metadata), now, runId] - ); - - orchestratorService.appendTimelineEvent({ - runId, - eventType: "phase_transition", - reason: transitionReason, - detail: { - fromPhaseKey: previousPhaseKey, - fromPhaseName: previousPhaseName, - toPhaseKey: targetPhase.phaseKey, - toPhaseName: targetPhase.name, - phaseModel: targetPhase.model, - phaseValidation: targetPhase.validationGate, - phaseBudget: targetPhase.budget ?? {}, - transitionedAt: now, - source: "coordinator_set_current_phase" - } - }); - orchestratorService.emitRuntimeUpdate({ - runId, - reason: "phase_transition" - }); - - return { - ok: true, - changed: true, - previousPhaseKey, - previousPhaseName, - currentPhaseKey: targetPhase.phaseKey, - currentPhaseName: targetPhase.name - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.set_current_phase.error", { phaseKey, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const create_task = defineCoordinatorTool({ - description: - "Create a task in the mission DAG. Tasks show up in the UI as a visual work breakdown. Use this to plan the work before spawning workers.", - inputSchema: z.object({ - key: z.string().describe("Unique key for this task (e.g. 'design-oauth', 'impl-tokens')"), - title: z.string().describe("Human-readable title for the task"), - description: z.string().describe("Description of what needs to be done"), - dependsOn: z - .array(z.string()) - .default([]) - .describe("Task keys this task depends on"), - phaseKey: z.string().optional().describe("Optional mission phase key/name to display this task under, for example: testing, validation, closeout"), - }), - execute: async ({ key, title, description, dependsOn, phaseKey }) => { - try { - const g = graph(); - const planningInputBlockReason = getPlanningInputBlockReason(g); - if (planningInputBlockReason) { - return { ok: false, error: planningInputBlockReason }; - } - const missionPhases = resolveMissionPhases(g); - const currentPhase = missionPhases.length > 0 ? resolveCurrentPhaseCard(g, missionPhases) : null; - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - if (currentPhaseKey === "planning") { - 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.`, - nextPhaseKey, - "planning_complete", - ); - } - const normalizedKey = String(key ?? "").trim(); - const normalizedTitle = String(title ?? "").trim(); - const normalizedDescription = String(description ?? "").trim(); - const requestedDependsOn = normalizeDependencyReferences({ - graph: g, - refs: dependsOn, - label: `Task '${normalizedKey}'`, - }); - if (!normalizedKey.length) { - return { ok: false, error: "Task key is required." }; - } - if (!normalizedTitle.length) { - return { ok: false, error: "Task title is required." }; - } - if (!normalizedDescription.length) { - return { ok: false, error: "Task description is required." }; - } - const inferredDependsOn = - requestedDependsOn.length === 0 && missionPhases.length > 0 - ? resolveImplicitDependencyStepKeys(g, missionPhases) - : []; - const normalizedDependsOn = - requestedDependsOn.length > 0 ? requestedDependsOn : inferredDependsOn; - const phaseMetadataResult = resolveTaskPhaseMetadata({ - phases: missionPhases, - requestedPhaseKey: phaseKey, - key: normalizedKey, - title: normalizedTitle, - fallbackMetadata: buildPhaseMetadataForNewStep(g, missionPhases), - }); - if (!phaseMetadataResult.ok) { - return { ok: false, error: phaseMetadataResult.error }; - } - const phaseMetadata = phaseMetadataResult.metadata; - const maxIndex = g.steps.reduce( - (max, s) => Math.max(max, s.stepIndex), - -1, - ); - const created = orchestratorService.addSteps({ - runId, - steps: [ - { - stepKey: normalizedKey, - title: normalizedTitle, - stepIndex: maxIndex + 1, - laneId: missionLaneId ?? undefined, - dependencyStepKeys: normalizedDependsOn, - executorKind: "manual", - metadata: { - ...phaseMetadata, - instructions: normalizedDescription, - isTask: true, - displayOnlyTask: true, - stepType: "task", - requestedDependencyStepKeys: requestedDependsOn, - ...(inferredDependsOn.length > 0 ? { inferredDependencyStepKeys: inferredDependsOn } : {}), - }, - }, - ], - }); - const newStep = created[0]; - if (newStep) { - onDagMutation({ - runId, - mutation: { type: "step_added", step: newStep }, - timestamp: nowIso(), - source: "coordinator", - }); - } - logger.info("coordinator.create_task", { key: normalizedKey, title: normalizedTitle }); - return { - ok: true, - taskKey: normalizedKey, - stepId: newStep?.id ?? null, - phaseKey: typeof phaseMetadata.phaseKey === "string" ? phaseMetadata.phaseKey : null, - phaseName: typeof phaseMetadata.phaseName === "string" ? phaseMetadata.phaseName : null, - status: newStep?.status ?? "unknown", - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.create_task.error", { key, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const update_task = defineCoordinatorTool({ - description: - "Update a task's status or description. Use this to mark tasks as done, failed, or to update their instructions.", - inputSchema: z.object({ - key: z.string().describe("Task key to update"), - status: z.enum(["succeeded", "failed", "skipped", "superseded"]).optional().describe("New status for the task"), - result: z.string().optional().describe("Result summary or failure reason"), - }), - execute: async ({ key, status, result }) => { - try { - const g = graph(); - const step = resolveStep(g, key); - if (!step) - return { ok: false, error: `Task not found: ${key}` }; - if (status === "skipped") { - orchestratorService.skipStep({ - runId, - stepId: step.id, - reason: result ?? "Skipped by coordinator", - }); - } else if (status) { - const ts = nowIso(); - db.run( - `update orchestrator_steps set status = ?, completed_at = ?, updated_at = ? where id = ? and run_id = ?`, - [status, ts, ts, step.id, runId], - ); - } - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: key, newStatus: status ?? step.status }, - timestamp: nowIso(), - source: "coordinator", - }); - logger.info("coordinator.update_task", { key, status }); - return { ok: true, taskKey: key, newStatus: status ?? step.status }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.update_task.error", { key, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const assign_task = defineCoordinatorTool({ - description: - "Assign a task to a worker. Links the task to the worker in the UI.", - inputSchema: z.object({ - taskKey: z.string().describe("Task key to assign"), - workerId: z.string().describe("Worker step key to assign the task to"), - }), - execute: async ({ taskKey, workerId }) => { - try { - const g = graph(); - const step = resolveStep(g, taskKey); - if (!step) - return { ok: false, error: `Task not found: ${taskKey}` }; - // Store assignment in step metadata - const existingMeta = (step.metadata ?? {}) as Record<string, unknown>; - const updatedMeta = { ...existingMeta, assignedTo: workerId }; - db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ? and run_id = ?`, - [JSON.stringify(updatedMeta), nowIso(), step.id, runId], - ); - logger.info("coordinator.assign_task", { taskKey, workerId }); - return { ok: true, taskKey, workerId }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.assign_task.error", { taskKey, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const list_tasks = defineCoordinatorTool({ - description: - "Get all tasks and their current statuses.", - inputSchema: z.object({}), - execute: async () => { - try { - const g = graph(); - const relevantSteps = filterExecutionSteps(g.steps); - const total = relevantSteps.length; - const byStatus: Record<string, number> = {}; - for (const step of relevantSteps) { - byStatus[step.status] = (byStatus[step.status] ?? 0) + 1; - } - const terminal = - (byStatus.succeeded ?? 0) + - (byStatus.failed ?? 0) + - (byStatus.skipped ?? 0) + - (byStatus.superseded ?? 0) + - (byStatus.canceled ?? 0); - const progressPct = total > 0 ? Math.round((terminal / total) * 100) : 0; - const tasks = g.steps.map((s) => { - const attempt = findRunningAttempt(g, s.id); - const meta = (s.metadata ?? {}) as Record<string, unknown>; - return { - key: s.stepKey, - title: s.title, - status: s.status, - assignedTo: meta.assignedTo ?? null, - hasRunningWorker: !!attempt, - retryCount: s.retryCount, - stepType: typeof meta.stepType === "string" ? meta.stepType : null, - }; - }); - return { - ok: true, - runId, - progressPct, - total, - byStatus, - tasks, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.list_tasks.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - // ─── Step Control ─────────────────────────────────────────── - - const skip_step = defineCoordinatorTool({ - description: - "Skip a step/task that you've decided is non-critical or unnecessary. Unblocks downstream steps that depend on it.", - inputSchema: z.object({ - workerId: z.string().describe("Step key of the step to skip"), - reason: z.string().describe("Why you're skipping this step"), - }), - execute: async ({ workerId, reason }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) - return { ok: false, error: `Step not found: ${workerId}` }; - if (step.status === "succeeded" || step.status === "skipped") - return { ok: false, error: `Step '${workerId}' is already ${step.status}` }; - // Cancel running attempt if any - const running = findRunningAttempt(g, step.id); - if (running) { - await orchestratorService.completeAttempt({ - attemptId: running.id, - status: "canceled", - errorClass: "canceled", - errorMessage: reason, - }); - } - orchestratorService.skipStep({ - runId, - stepId: step.id, - reason, - allowTerminal: step.status === "failed" || step.status === "canceled", - }); - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: workerId, newStatus: "skipped" }, - timestamp: nowIso(), - source: "coordinator", - }); - logger.info("coordinator.skip_step", { workerId, reason }); - return { ok: true, workerId, newStatus: "skipped" }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.skip_step.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const mark_step_complete = defineCoordinatorTool({ - description: - "Mark a step as succeeded. Use when YOU (the coordinator) have verified a worker's output is satisfactory, or when completing a task/milestone yourself.", - inputSchema: z.object({ - workerId: z.string().optional().describe("Step key of the step to mark complete"), - stepId: z.string().optional().describe("Alias accepted for workerId; may be the step key or internal step id"), - id: z.string().optional().describe("Alias accepted for workerId; may be the step key or internal step id"), - summary: z.string().optional().describe("Optional completion summary"), - }), - execute: async ({ workerId, stepId, id, summary }) => { - try { - const g = graph(); - const stepRef = [workerId, stepId, id] - .map((candidate) => (typeof candidate === "string" ? candidate.trim() : "")) - .find((candidate) => candidate.length > 0) ?? ""; - if (!stepRef) { - return { - ok: false, - error: "mark_step_complete requires workerId. stepId/id aliases are accepted when they refer to an existing step key or internal step id.", - }; - } - const step = resolveStep(g, stepRef); - if (!step) return { ok: false, error: `Step not found: ${stepRef}` }; - const resolvedWorkerId = step.stepKey; - const stepMeta = asRecord(step.metadata) ?? {}; - const validationContract = parseValidationContract(stepMeta.validationContract ?? null); - const validationState = resolveValidationStateFromStepMetadata(stepMeta); - const recoveringFailedStep = step.status === "failed" && validationState === "pass"; - if (TERMINAL_STEP_STATUSES.has(step.status) && !recoveringFailedStep) { - return { ok: false, error: `Step '${resolvedWorkerId}' is already terminal (${step.status})` }; - } - if (validationContract?.required && validationState !== "pass") { - return { - ok: false, - error: `Step '${resolvedWorkerId}' requires validator pass before completion (current validation state: ${validationState}).`, - hint: "Run report_validation with verdict='pass' for this step before marking it complete.", - validation: { - required: true, - state: validationState, - tier: validationContract.tier, - criteria: validationContract.criteria - } - }; - } - // Cancel running attempt if any - const running = findRunningAttempt(g, step.id); - if (running) { - await orchestratorService.completeAttempt({ - attemptId: running.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: summary ?? "Marked complete by coordinator", - outputs: null, - warnings: [], - sessionId: running.executorSessionId ?? null, - trackedSession: false, - }, - }); - } - const ts = nowIso(); - db.run( - `update orchestrator_steps set status = 'succeeded', completed_at = ?, updated_at = ? where id = ? and run_id = ?`, - [ts, ts, step.id, runId], - ); - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: resolvedWorkerId, newStatus: "succeeded" }, - timestamp: ts, - source: "coordinator", - }); - // Trigger autopilot to pick up newly unblocked steps - setTimeout(() => { - void orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_mark_step_complete", - }).catch((error) => { - logger.debug("coordinator.mark_step_complete.autopilot_schedule_failed", { - runId, - workerId: resolvedWorkerId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, 100); - logger.info("coordinator.mark_step_complete", { workerId: resolvedWorkerId, summary }); - return { ok: true, workerId: resolvedWorkerId, stepId: step.id, newStatus: "succeeded" }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.mark_step_complete.error", { workerId, stepId, id, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const mark_step_failed = defineCoordinatorTool({ - description: - "Mark a step as failed. Use when YOU (the coordinator) have determined a worker's output is unsatisfactory or the task cannot be completed as planned. After marking failed, you can retry_step with adjusted instructions or skip_step.", - inputSchema: z.object({ - workerId: z.string().describe("Step key of the step to mark failed"), - reason: z.string().describe("Why the step failed"), - }), - execute: async ({ workerId, reason }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) return { ok: false, error: `Step not found: ${workerId}` }; - if (TERMINAL_STEP_STATUSES.has(step.status)) { - return { ok: false, error: `Step '${workerId}' is already terminal (${step.status})` }; - } - // Cancel running attempt if any - const running = findRunningAttempt(g, step.id); - if (running) { - await orchestratorService.completeAttempt({ - attemptId: running.id, - status: "failed", - errorClass: "deterministic", - errorMessage: reason, - }); - } - const ts = nowIso(); - db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ?, last_error = ? where id = ? and run_id = ?`, - [ts, ts, reason, step.id, runId], - ); - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: workerId, newStatus: "failed" }, - timestamp: ts, - source: "coordinator", - }); - logger.info("coordinator.mark_step_failed", { workerId, reason }); - return { ok: true, workerId, newStatus: "failed", reason }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.mark_step_failed.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const retry_step = defineCoordinatorTool({ - description: - "Retry a failed step with adjusted instructions. Creates a new attempt with the revised prompt. Use when a worker failed but you believe it can succeed with different guidance.", - inputSchema: z.object({ - workerId: z.string().describe("Step key of the failed step to retry"), - adjustedInstructions: z.string().describe("New/revised instructions for the retry — explain what went wrong and how to fix it"), - }), - execute: async ({ workerId, adjustedInstructions }) => { - try { - const g = graph(); - const step = resolveStep(g, workerId); - if (!step) - return { ok: false, error: `Step not found: ${workerId}` }; - const running = findRunningAttempt(g, step.id); - if (running) { - return { - ok: false, - error: `Cannot retry step '${workerId}' while attempt '${running.id}' is still running.` - }; - } - if (step.status !== "failed") { - return { - ok: false, - error: `Step '${workerId}' must be failed before retry (current status: ${step.status}).` - }; - } - const existingMeta = (step.metadata ?? {}) as Record<string, unknown>; - const lastFailedAttempt = step.lastAttemptId - ? g.attempts.find((attempt) => attempt.id === step.lastAttemptId) ?? null - : [...g.attempts].reverse().find((attempt) => attempt.stepId === step.id && attempt.status === "failed") ?? null; - const canGrantInfraRetry = - step.retryCount >= step.retryLimit - && isAppRestartOrphanAttempt(lastFailedAttempt) - && existingMeta.infraRetryLimitExtended !== true; - if (step.retryCount >= step.retryLimit && !canGrantInfraRetry) { - return { - ok: false, - error: `Step '${workerId}' has exhausted its retry limit (${step.retryCount}/${step.retryLimit}).` - }; - } - // Update step metadata with revised instructions - const originalInstructions = existingMeta.instructions; - const ts = nowIso(); - const updatedMeta = { - ...existingMeta, - instructions: adjustedInstructions, - previousInstructions: originalInstructions, - retriedByCoordinator: true, - retryReason: `Coordinator retry at ${ts}`, - ...(canGrantInfraRetry ? { - infraRetryLimitExtended: true, - infraRetryLimitExtendedAt: ts, - infraRetryLimitExtendedReason: "app_restart_orphan", - } : {}), - }; - const nextRetryLimit = canGrantInfraRetry - ? Math.max(step.retryLimit, step.retryCount + 1) - : step.retryLimit; - // Reset step to pending so autopilot picks it up - db.run( - `update orchestrator_steps set status = 'pending', metadata_json = ?, retry_count = retry_count + 1, retry_limit = ?, completed_at = null, updated_at = ? where id = ? and run_id = ?`, - [JSON.stringify(updatedMeta), nextRetryLimit, ts, step.id, runId], - ); - onDagMutation({ - runId, - mutation: { type: "status_changed", stepKey: workerId, newStatus: "pending" }, - timestamp: ts, - source: "coordinator", - }); - // Trigger autopilot to start the retry - setTimeout(() => { - void orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_retry_step", - }).catch((error) => { - logger.debug("coordinator.retry_step.autopilot_schedule_failed", { - runId, - workerId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, 100); - logger.info("coordinator.retry_step", { workerId }); - return { - ok: true, - workerId, - newStatus: "pending", - retryCount: step.retryCount + 1, - retryLimit: nextRetryLimit, - infraRetryLimitExtended: canGrantInfraRetry, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.retry_step.error", { workerId, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - // ─── Mission Lifecycle ──────────────────────────────────────── - - const complete_mission = defineCoordinatorTool({ - description: - "Request mission success finalization. The runtime still enforces completion gates before success is granted.", - inputSchema: z.object({ - summary: z.string().describe("Summary of what was accomplished"), - }), - execute: async ({ summary }) => { - try { - const g = graph(); - const relevantSteps = filterExecutionSteps(g.steps); - const activeWorkers = relevantSteps - .filter((step) => step.status === "running") - .map((step) => ({ - stepKey: step.stepKey, - title: step.title, - })); - if (activeWorkers.length > 0) { - return { - ok: false, - error: `Mission cannot be completed while ${activeWorkers.length} worker(s) are still running.`, - hint: "Wait for running workers to finish, or cancel them explicitly if the work should be abandoned.", - activeWorkers, - }; - } - const blockers = relevantSteps - .filter((step) => step.status === "succeeded") - .flatMap((step) => { - const stepMeta = asRecord(step.metadata) ?? {}; - const validationContract = parseValidationContract(stepMeta.validationContract ?? null); - if (isAutoSpawnedValidationProofStep(step)) return []; - if (!validationContract?.required) return []; - const validationState = resolveValidationStateFromStepMetadata(stepMeta); - if (validationState === "pass") return []; - return [{ - stepKey: step.stepKey, - status: step.status, - validationState, - tier: validationContract.tier, - criteria: validationContract.criteria - }]; - }); - if (blockers.length > 0) { - return { - ok: false, - error: `Mission cannot be completed: ${blockers.length} step(s) require validator pass before completion.`, - hint: "Submit passing validation reports for blocked steps before completing the mission.", - blockers - }; - } - const finalized = orchestratorService.finalizeRun({ runId }); - if (!finalized.finalized) { - return { - ok: false, - error: "Mission cannot be completed until the runtime completion gates pass.", - blockers: finalized.blockers, - }; - } - if (finalized.finalStatus !== "succeeded") { - return { - ok: false, - error: `Mission completion request did not resolve to success (final status: ${finalized.finalStatus}).`, - blockers: finalized.blockers, - finalStatus: finalized.finalStatus, - }; - } - - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "done", - payload: { summary, completedBy: "coordinator" }, - }); - if (deps.onRunFinalize) { - deps.onRunFinalize({ runId, succeeded: true, summary }); - } - logger.info("coordinator.complete_mission", { runId, summary }); - return { ok: true, runId, summary, finalStatus: finalized.finalStatus }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.complete_mission.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const fail_mission = defineCoordinatorTool({ - description: - "Declare the mission failed. Use when you determine the mission cannot succeed.", - inputSchema: z.object({ - reason: z.string().describe("Reason why the mission failed"), - }), - execute: async ({ reason }) => { - try { - const markRunFailed = () => { - const ts = nowIso(); - db.run( - ` - update orchestrator_runs - set status = 'failed', - completed_at = ?, - updated_at = ?, - last_error = ? - where id = ? - and (? is null or project_id = ?) - `, - [ts, ts, reason, runId, projectId, projectId], - ); - try { - missionService.update({ - missionId, - status: "failed", - lastError: reason, - outcomeSummary: reason, - }); - } catch { - // Mission status synchronization is best-effort here; the run is authoritative. - } - try { - deps.orchestratorService.generateRunRetrospective({ runId }); - } catch { - // best-effort - } - }; - - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "done", - payload: { reason, failedBy: "coordinator", failed: true }, - }); - if (deps.onRunFinalize) { - deps.onRunFinalize({ runId, succeeded: false, reason }); - } else { - markRunFailed(); - } - if (deps.onRunFinalize) markRunFailed(); - logger.info("coordinator.fail_mission", { runId, reason }); - return { ok: true, runId, reason }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.fail_mission.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const openHumanIntervention = (args: { - question: string; - context?: string | null; - urgency?: "low" | "normal" | "high"; - source: "ask_user" | "request_user_input"; - canProceedWithoutAnswer?: boolean; - }) => { - const question = args.question.trim(); - if (!question.length) { - return { ok: false as const, error: "Question is required." }; - } - const context = typeof args.context === "string" ? args.context.trim() : ""; - const urgency = args.urgency ?? "normal"; - const mission = missionService.get(missionId); - if (!mission) { - return { ok: false as const, error: `Mission not found: ${missionId}` }; - } - const currentGraph = graph(); - const currentPhase = resolveCurrentPhaseCard(currentGraph, resolveMissionPhases(currentGraph)); - - const existing = mission.interventions.find((entry) => { - if (entry.status !== "open" || entry.interventionType !== "manual_input") return false; - const metadata = asRecord(entry.metadata); - return ( - metadata?.runId === runId - && metadata?.source === args.source - && metadata?.question === question - ); - }); - if (existing) { - return { - ok: true as const, - interventionId: existing.id, - question, - deduped: true, - awaitingUserResponse: args.canProceedWithoutAnswer !== true, - blocking: args.canProceedWithoutAnswer !== true, - }; - } - - const title = question.length > 96 - ? "Coordinator requested user input" - : `Coordinator input needed: ${question}`; - const body = context.length > 0 ? `${question}\n\nContext:\n${context}` : question; - const intervention = missionService.addIntervention({ - missionId, - interventionType: "manual_input", - title, - body, - requestedAction: args.canProceedWithoutAnswer - ? "Optional: provide guidance. Coordinator may continue with best-effort assumptions." - : "Provide guidance to unblock coordinator execution.", - pauseMission: args.canProceedWithoutAnswer !== true, - metadata: { - source: args.source, - runId, - question, - context: context.length > 0 ? context : null, - urgency, - canProceedWithoutAnswer: args.canProceedWithoutAnswer === true, - blocking: args.canProceedWithoutAnswer !== true, - category: "user_input" as const, - ...buildCoordinatorQuestionOwnerMetadata({ - phaseKey: currentPhase?.phaseKey ?? null, - phaseName: currentPhase?.name ?? null, - }), - } - }); - - // If the intervention is blocking, also pause the orchestrator run - if (args.canProceedWithoutAnswer !== true) { - try { - orchestratorService.pauseRun({ - runId, - reason: `Blocking user input required: ${question.slice(0, 100)}`, - metadata: { interventionSource: args.source, interventionId: intervention.id }, - }); - } catch (e) { - // Run may already be paused or in a terminal state - logger.warn(`Could not pause run for blocking intervention: ${e}`); - } - } - - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "intervention_opened", - payload: { - missionId, - interventionId: intervention.id, - interventionType: intervention.interventionType, - source: args.source, - question, - context: context.length > 0 ? context : null, - urgency, - canProceedWithoutAnswer: args.canProceedWithoutAnswer === true, - blocking: args.canProceedWithoutAnswer !== true, - category: "user_input", - phase: currentPhase?.phaseKey ?? null - }, - }); - orchestratorService.appendTimelineEvent({ - runId, - eventType: "intervention_opened", - reason: "coordinator_escalation", - detail: { - interventionId: intervention.id, - source: args.source, - urgency, - blocking: args.canProceedWithoutAnswer !== true - } - }); - logger.info("coordinator.user_input_requested", { - runId, - missionId, - interventionId: intervention.id, - source: args.source, - urgency, - blocking: args.canProceedWithoutAnswer !== true - }); - return { - ok: true as const, - interventionId: intervention.id, - question, - deduped: false, - awaitingUserResponse: args.canProceedWithoutAnswer !== true, - blocking: args.canProceedWithoutAnswer !== true, - }; - }; - - const get_budget_status = defineCoordinatorTool({ - description: - "Get the current mission budget pressure and usage snapshot. Use this before deciding parallelism, validation depth, or model strategy.", - inputSchema: z.object({ - includePerPhase: z.boolean().default(true).describe("Include per-phase budget usage details."), - includePerWorker: z.boolean().default(false).describe("Include per-worker budget usage details."), - }), - execute: async ({ includePerPhase, includePerWorker }) => { - const current = graph(); - const activeStep = - current.steps.find((step) => step.status === "running") - ?? current.steps.find((step) => step.status === "ready") - ?? null; - const activeMeta = asRecord(activeStep?.metadata); - const currentPhaseKey = typeof activeMeta?.phaseKey === "string" ? activeMeta.phaseKey.trim() : ""; - const currentPhaseName = typeof activeMeta?.phaseName === "string" ? activeMeta.phaseName.trim() : ""; - - if (!getMissionBudgetStatus) { - const activeWorkers = current.attempts.filter((attempt) => attempt.status === "running").length; - return { - ok: true, - pressure: "normal", - mode: "unknown", - mission: { used: 0, limit: null, remaining: null }, - currentPhase: currentPhaseKey.length > 0 || currentPhaseName.length > 0 - ? { phaseKey: currentPhaseKey || "unknown", phaseName: currentPhaseName || currentPhaseKey || "Current phase", used: 0, limit: null, remaining: null } - : null, - activeWorkers, - recommendation: "Budget service unavailable; use conservative parallelism until telemetry is available." - }; - } - - try { - const snapshot = await getMissionBudgetStatus(); - if (!snapshot) { - return { ok: false, error: "Mission budget status unavailable." }; - } - const phaseSnapshot = - (currentPhaseKey.length > 0 - ? snapshot.perPhase.find((phase) => phase.phaseKey === currentPhaseKey) - : null) - ?? (currentPhaseName.length > 0 - ? snapshot.perPhase.find((phase) => phase.phaseName === currentPhaseName) - : null) - ?? null; - - // Emit soft budget warnings on pressure transitions (deduped) - if ( - deps.onBudgetWarning && - (snapshot.pressure === "warning" || snapshot.pressure === "critical") && - snapshot.pressure !== lastEmittedBudgetPressure - ) { - const pctUsed = snapshot.mission.maxTokens - ? Math.round((snapshot.mission.usedTokens / snapshot.mission.maxTokens) * 100) - : null; - const costDetail = snapshot.mission.usedCostUsd != null - ? `$${snapshot.mission.usedCostUsd.toFixed(2)} spent` - : null; - const parts = [ - pctUsed != null ? `${pctUsed}% of token budget used` : null, - costDetail, - snapshot.recommendation, - ].filter(Boolean); - const detail = parts.join("; ") || `Budget pressure is now ${snapshot.pressure}`; - lastEmittedBudgetPressure = snapshot.pressure; - deps.onBudgetWarning(snapshot.pressure, detail); - logger.info("coordinator.budget_soft_warning_emitted", { - runId, - missionId, - pressure: snapshot.pressure, - }); - } else if (snapshot.pressure === "normal" && lastEmittedBudgetPressure !== "normal") { - // Reset dedup tracker when pressure drops back to normal - lastEmittedBudgetPressure = "normal"; - } - - return { - ok: true, - pressure: snapshot.pressure, - mode: snapshot.mode, - mission: { - used: snapshot.mission.usedTokens, - limit: snapshot.mission.maxTokens ?? null, - remaining: snapshot.mission.remainingTokens ?? null, - usedCostUsd: snapshot.mission.usedCostUsd, - limitCostUsd: snapshot.mission.maxCostUsd ?? null, - remainingCostUsd: snapshot.mission.remainingCostUsd ?? null, - usedTimeMs: snapshot.mission.usedTimeMs, - limitTimeMs: snapshot.mission.maxTimeMs ?? null, - remainingTimeMs: snapshot.mission.remainingTimeMs ?? null, - }, - currentPhase: phaseSnapshot - ? { - phaseKey: phaseSnapshot.phaseKey, - phaseName: phaseSnapshot.phaseName, - used: phaseSnapshot.usedTokens, - limit: phaseSnapshot.maxTokens ?? null, - remaining: phaseSnapshot.remainingTokens ?? null, - usedCostUsd: phaseSnapshot.usedCostUsd, - usedTimeMs: phaseSnapshot.usedTimeMs - } - : null, - activeWorkers: snapshot.activeWorkers, - recommendation: snapshot.recommendation, - estimatedRemainingCapacity: snapshot.estimatedRemainingCapacity, - rateLimits: snapshot.rateLimits, - perProvider: snapshot.perProvider.map((prov) => ({ - provider: prov.provider, - fiveHour: { - usedTokens: prov.fiveHour.usedTokens, - limitTokens: prov.fiveHour.limitTokens, - usedPct: prov.fiveHour.usedPct, - usedCostUsd: prov.fiveHour.usedCostUsd, - timeUntilResetMs: prov.fiveHour.timeUntilResetMs, - }, - weekly: { - usedTokens: prov.weekly.usedTokens, - limitTokens: prov.weekly.limitTokens, - usedPct: prov.weekly.usedPct, - usedCostUsd: prov.weekly.usedCostUsd, - timeUntilResetMs: prov.weekly.timeUntilResetMs, - }, - })), - hardCaps: { - fiveHourHardStopPercent: snapshot.hardCaps.fiveHourHardStopPercent, - weeklyHardStopPercent: snapshot.hardCaps.weeklyHardStopPercent, - apiKeyMaxSpendUsd: snapshot.hardCaps.apiKeyMaxSpendUsd, - apiKeySpentUsd: snapshot.hardCaps.apiKeySpentUsd, - fiveHourTriggered: snapshot.hardCaps.fiveHourTriggered, - weeklyTriggered: snapshot.hardCaps.weeklyTriggered, - apiKeyTriggered: snapshot.hardCaps.apiKeyTriggered, - anyTriggered: snapshot.hardCaps.fiveHourTriggered || snapshot.hardCaps.weeklyTriggered || snapshot.hardCaps.apiKeyTriggered, - }, - ...(includePerPhase - ? { - perPhase: snapshot.perPhase.map((phase) => ({ - phaseKey: phase.phaseKey, - phaseName: phase.phaseName, - used: phase.usedTokens, - limit: phase.maxTokens ?? null, - remaining: phase.remainingTokens ?? null, - usedCostUsd: phase.usedCostUsd, - usedTimeMs: phase.usedTimeMs - })) - } - : {}), - ...(includePerWorker - ? { - perWorker: snapshot.perWorker.map((worker) => ({ - workerId: worker.stepKey, - stepId: worker.stepId, - title: worker.title, - phaseKey: worker.phaseKey, - phaseName: worker.phaseName, - used: worker.usedTokens, - limit: worker.maxTokens ?? null, - remaining: worker.remainingTokens ?? null, - usedCostUsd: worker.usedCostUsd, - usedTimeMs: worker.usedTimeMs - })) - } - : {}) - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.get_budget_status.error", { runId, missionId, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const ask_user = defineCoordinatorTool({ - description: - "Open one or more structured blocking questions for the current phase. Prefer letting the active phase worker ask these directly instead of the coordinator. Bundle all related questions in one call.", - inputSchema: z.object({ - questions: z.array(z.object({ - question: z.string().describe("The question text"), - context: z.string().optional().describe("Additional context for this question"), - options: z.array(z.string()).optional().describe("Optional multiple-choice options"), - defaultAssumption: z.string().optional().describe("What you will assume if the user does not answer"), - impact: z.string().optional().describe("Why this question matters / what it affects"), - })).min(1).describe("Array of structured questions to ask"), - phase: z.string().optional().describe("Which phase this quiz is for"), - }), - execute: async ({ questions, phase }) => { - try { - const g = graph(); - const questionBlockReason = getQuestionAskingBlockReason(g); - if (questionBlockReason) { - return { ok: false as const, error: questionBlockReason }; - } - const policy = resolveCurrentPhaseQuestionPolicy(g); - if (policy.isPlanning) { - return { - ok: false as const, - error: "During Planning, the active planning worker must ask any clarification directly. Spawn or wait for the planner instead of opening coordinator-owned planning questions.", - }; - } - const priorInterventions = listPhaseQuestionInterventions(g); - const openQuestion = priorInterventions.find((entry) => entry.status === "open") ?? null; - if (openQuestion) { - const openMeta = asRecord(openQuestion.metadata); - return { - ok: true as const, - interventionId: openQuestion.id, - questionCount: Math.max(1, Number(openMeta?.questionCount ?? 1) || 1), - deduped: true, - awaitingUserResponse: true as const, - blocking: true as const, - }; - } - if (!policy.enabled) { - return { ok: false as const, error: "Ask Questions is disabled for the current phase." }; - } - // VAL-PLAN-006: Multi-round deliberation — when phase has canLoop=true, - // bypass the maxQuestions ceiling to allow unbounded ask_user + re-plan cycles. - const phaseCanLoop = policy.phase?.orderingConstraints?.canLoop === true; - if (!phaseCanLoop && policy.maxQuestions != null && priorInterventions.length >= policy.maxQuestions) { - return { - ok: false as const, - error: `This phase already reached its Ask Questions limit (${policy.maxQuestions}). Continue with the best grounded assumptions you can.`, - }; - } - const firstQuestion = questions[0].question.trim(); - if (!firstQuestion.length) { - return { ok: false as const, error: "First question text is required." }; - } - - const mission = missionService.get(missionId); - if (!mission) { - return { ok: false as const, error: `Mission not found: ${missionId}` }; - } - - const title = questions.length === 1 - ? (firstQuestion.length > 96 ? "Coordinator question ready" : `Question: ${firstQuestion}`) - : `Coordinator has ${questions.length} questions`; - const bodyLines = questions.map((q: { question: string }, i: number) => `Q${i + 1}: ${q.question}`); - const body = bodyLines.join("\n"); - - const intervention = missionService.addIntervention({ - missionId, - interventionType: "manual_input", - title, - body, - requestedAction: "Answer the planning questions to unblock coordinator execution.", - pauseMission: true, - metadata: { - source: "ask_user", - runId, - quizMode: true, - canProceedWithoutAnswer: false, - blocking: true, - category: "user_input" as const, - questions, - ...buildCoordinatorQuestionOwnerMetadata({ - phaseKey: phase ?? policy.phaseKey ?? null, - phaseName: policy.phase?.name ?? null, - }), - questionCount: questions.length, - } - }); - - // ask_user is always blocking (planning phase) — pause the run - try { - orchestratorService.pauseRun({ - runId, - reason: `Blocking planning questions (${questions.length}): ${firstQuestion.slice(0, 100)}`, - metadata: { interventionSource: "ask_user", interventionId: intervention.id }, - }); - } catch (e) { - // Run may already be paused or in a terminal state - logger.warn(`Could not pause run for ask_user intervention: ${e}`); - } - - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "intervention_opened", - payload: { - missionId, - interventionId: intervention.id, - interventionType: intervention.interventionType, - source: "ask_user", - quizMode: true, - blocking: true, - category: "user_input", - questionCount: questions.length, - phase: phase ?? policy.phaseKey ?? null, - }, - }); - orchestratorService.appendTimelineEvent({ - runId, - eventType: "intervention_opened", - reason: "coordinator_escalation", - detail: { - interventionId: intervention.id, - source: "ask_user", - quizMode: true, - blocking: true, - questionCount: questions.length, - } - }); - logger.info("coordinator.ask_user_quiz", { - runId, - missionId, - interventionId: intervention.id, - questionCount: questions.length, - blocking: true, - phase: phase ?? policy.phaseKey ?? null, - }); - return { - ok: true as const, - interventionId: intervention.id, - questionCount: questions.length, - deduped: false, - awaitingUserResponse: true as const, - blocking: true as const, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.ask_user.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const request_user_input = defineCoordinatorTool({ - description: - "Request user guidance from the coordinator flow. Prefer this over direct worker-to-human escalation.", - inputSchema: z.object({ - question: z.string().describe("The exact question for the user."), - context: z.string().optional().describe("Optional context and current assumptions."), - urgency: z.enum(["low", "normal", "high"]).default("normal"), - canProceedWithoutAnswer: z - .boolean() - .default(false) - .describe("Whether coordinator can continue with assumptions if no response arrives.") - }), - execute: async ({ question, context, urgency, canProceedWithoutAnswer }) => { - try { - return openHumanIntervention({ - source: "request_user_input", - question, - context: context ?? null, - urgency, - canProceedWithoutAnswer - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.request_user_input.error", { error: msg }); - return { ok: false, error: msg }; - } - } - }); - - // ─── Context Tools ──────────────────────────────────────────── - - const read_file = defineCoordinatorTool({ - description: - "Read a file from the project. Use to inspect code, configs, or docs when you need to understand the codebase.", - inputSchema: z.object({ - filePath: z.string().describe("Path relative to project root, e.g. 'src/index.ts' or 'package.json'"), - maxLines: z.number().optional().describe("Maximum number of lines to read (default: 200)"), - }), - execute: async ({ filePath, maxLines }) => { - try { - const g = graph(); - const planningReadBlockReason = getPlanningRepoReadBlockReason(g); - if (planningReadBlockReason) { - return { ok: false, error: planningReadBlockReason }; - } - const fullPath = resolveWorkspacePath(filePath, true); - if (!fullPath) { - return { ok: false, error: "Path is outside mission workspace root" }; - } - try { - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - const entries = fs.readdirSync(fullPath).slice(0, 100); - return { ok: true, type: "directory", entries }; - } - const content = fs.readFileSync(fullPath, "utf-8"); - const lines = content.split("\n"); - const limit = maxLines ?? 200; - const truncated = lines.length > limit; - const result = truncated ? lines.slice(0, limit).join("\n") : content; - return { - ok: true, - type: "file", - filePath, - content: result, - totalLines: lines.length, - truncated, - }; - } catch (error) { - return { ok: false, error: formatWorkspaceReadError("file", filePath, error) }; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, error: msg }; - } - }, - }); - - const read_step_output = defineCoordinatorTool({ - description: - "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 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 { - throw lastError; - } catch (error) { - return { ok: false, error: formatWorkspaceReadError("step output", stepKey, error) }; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, error: msg }; - } - }, - }); - - const search_files = defineCoordinatorTool({ - description: - "Search project files by name pattern or content. Use to find relevant code or files.", - inputSchema: z.object({ - pattern: z.string().describe("Search pattern — a filename glob (e.g. '**/*.ts') or literal content text"), - searchType: z.enum(["filename", "content"]).default("content").describe("Whether to search file names or file content"), - maxResults: z.number().optional().describe("Maximum results to return (default: 20)"), - explicitRegexMode: z.boolean().default(false).describe("Treat content patterns as regular expressions instead of literal text."), - }), - execute: async ({ pattern, searchType, maxResults, explicitRegexMode }) => { - try { - const g = graph(); - const planningReadBlockReason = getPlanningRepoReadBlockReason(g); - if (planningReadBlockReason) { - return { ok: false, error: planningReadBlockReason }; - } - const limit = maxResults ?? 20; - if (searchType === "filename") { - // Simple recursive file listing with glob matching - const results: string[] = []; - const filenameRegex = filenamePatternToRegExp(pattern); - const visited = new Set<string>(); - const walkDir = (dir: string, depth = 0) => { - if (depth > 6 || results.length >= limit) return; - try { - const realDir = fs.realpathSync(dir); - if (visited.has(realDir)) return; - visited.add(realDir); - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (results.length >= limit) break; - if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const fullPath = resolveWorkspacePath(path.join(dir, entry.name)); - if (!fullPath) continue; - const rel = path.relative(resolvedWorkspaceRootReal, fullPath); - try { - if (fs.statSync(fullPath).isDirectory()) { - walkDir(fullPath, depth + 1); - } else if (filenameRegex.test(rel.replace(/\\/g, "/"))) { - results.push(rel); - } - } catch { - // Skip entries whose stat fails (broken symlinks, etc.) - } - } - } catch { - // Skip unreadable dirs - } - }; - walkDir(resolvedWorkspaceRoot); - return { ok: true, searchType, pattern, results, total: results.length }; - } - // Content search using a simple line-by-line grep - const results: Array<{ file: string; line: number; text: string }> = []; - const regex = compileContentSearchRegex(pattern, explicitRegexMode); - const visited = new Set<string>(); - const walkDir = (dir: string, depth = 0) => { - if (depth > 6 || results.length >= limit) return; - try { - const realDir = fs.realpathSync(dir); - if (visited.has(realDir)) return; - visited.add(realDir); - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (results.length >= limit) break; - if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") continue; - const fullPath = resolveWorkspacePath(path.join(dir, entry.name)); - if (!fullPath) continue; - try { - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - walkDir(fullPath, depth + 1); - } else { - if (stat.size > 500_000) continue; // Skip large files - const content = fs.readFileSync(fullPath, "utf-8"); - const lines = content.split("\n"); - for (let i = 0; i < lines.length && results.length < limit; i++) { - if (regex.test(lines[i]!)) { - results.push({ - file: path.relative(resolvedWorkspaceRootReal, fullPath), - line: i + 1, - text: lines[i]!.slice(0, 200), - }); - } - } - } - } catch { - // Skip entries whose stat/read fails (broken symlinks, etc.) - } - } - } catch { - // Skip unreadable dirs - } - }; - walkDir(resolvedWorkspaceRoot); - return { ok: true, searchType, pattern, results, total: results.length }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, error: msg }; - } - }, - }); - - const get_project_context = defineCoordinatorTool({ - description: - "Get a summary of the project: key docs, file structure, and config. Use at mission start to understand the codebase.", - inputSchema: z.object({}), - execute: async () => { - try { - // Read key project files - const keyFiles = ["package.json", "tsconfig.json", "README.md", "CLAUDE.md"]; - const docs: Record<string, string> = {}; - for (const f of keyFiles) { - const fp = resolveWorkspacePath(f, true); - if (!fp) continue; - try { - const content = fs.readFileSync(fp, "utf-8"); - docs[f] = content.slice(0, 4_000); - } catch { - // Skip missing/unreadable files - } - } - // Top-level directory listing - let topLevel: string[] = []; - try { - topLevel = fs.readdirSync(workspaceRoot) - .filter((e) => !e.startsWith(".")) - .slice(0, 50); - } catch { - // Ignore - } - const workspaceRootLabel = "./"; - return { - ok: true, - projectRoot: workspaceRootLabel, - projectRootRedacted: true, - rootLabel: workspaceRootLabel, - topLevelEntries: topLevel, - docs, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, error: msg }; - } - }, - }); - - // ─── Sub-Agent Delegation ────────────────────────────────────── - - const delegate_to_subagent = defineCoordinatorTool({ - description: - "Delegate a subtask to a child agent under an existing worker. Creates a child step linked to the parent worker. Use this for nested decomposition when a worker's task naturally splits into sub-problems.", - inputSchema: z.object({ - parentWorkerId: z.string().describe("Step key of the parent worker that owns this subtask"), - name: z.string().describe("Human-readable name for the sub-agent"), - prompt: z.string().describe("Full task prompt for the sub-agent"), - modelId: z.string().optional().describe("Optional model ID override for the sub-agent"), - role: z.string().optional().describe("Optional team role to bind (e.g. implementer, validator)"), - }), - execute: async ({ parentWorkerId, name, prompt, modelId, role }) => { - try { - const g = graph(); - const planningInputBlockReason = getPlanningInputBlockReason(g); - if (planningInputBlockReason) { - return { ok: false, error: planningInputBlockReason }; - } - const planningQuestionPolicyBlockReason = getPlanningQuestionPolicyBlockReason(g); - if (planningQuestionPolicyBlockReason) { - return { ok: false, error: planningQuestionPolicyBlockReason }; - } - const teamRuntime = resolveTeamRuntimeConfig(g); - - // Hard constraint: allowSubAgents must be enabled - const subAgentsAllowed = teamRuntime?.allowSubAgents !== false; - if (!subAgentsAllowed) { - return { ok: false, error: "Sub-agent delegation is disabled (allowSubAgents=false). Use spawn_worker instead." }; - } - - // Verify parent worker exists and is not terminal - const parentStep = resolveStep(g, parentWorkerId); - if (!parentStep) { - return { ok: false, error: `Parent worker '${parentWorkerId}' not found.` }; - } - if (TERMINAL_STEP_STATUSES.has(parentStep.status)) { - return { - ok: false, - error: `Parent worker '${parentWorkerId}' is already ${parentStep.status}. Cannot delegate to a completed worker.`, - }; - } - - const spawnPolicy = authorizeWorkerSpawnPolicy({ - g, - requestedModelId: modelId, - }); - if (!spawnPolicy.ok) { - if (spawnPolicy.blockedByPhaseOrdering) { - return phaseOrderingBlockedResult( - spawnPolicy.error, - spawnPolicy.phaseTransitionRequiredKey, - spawnPolicy.phaseTransitionRequiredReason, - ); - } - return { ok: false, error: spawnPolicy.error }; - } - const resolvedModelId = spawnPolicy.resolvedModelId; - const resolvedProvider = spawnPolicy.resolvedProvider; - const resolvedDescriptor = resolveModelDescriptor(resolvedModelId); - if (!resolvedDescriptor) { - return { ok: false, error: `Model '${resolvedModelId}' is not registered.` }; - } - - // Hard constraint: allowClaudeAgentTeams must be enabled for Claude CLI sub-agents - if (resolvedProvider === "claude" && resolvedDescriptor.isCliWrapped && teamRuntime?.allowClaudeAgentTeams === false) { - return { - ok: false, - error: "Claude agent teams are disabled (allowClaudeAgentTeams=false). Cannot delegate claude sub-agent.", - }; - } - - // Validate role if specified - const normalizedRole = typeof role === "string" ? role.trim() : ""; - if (normalizedRole.length > 0 && !resolveRoleDefinition(teamRuntime, normalizedRole)) { - return { ok: false, error: `Unknown role '${normalizedRole}' in active team template.` }; - } - const missionPhases = resolveMissionPhases(g); - const phaseContext = resolveConfiguredPhaseContext(g, missionPhases); - if (!phaseContext.ok) { - return { ok: false, error: phaseContext.error }; - } - const currentPhase = phaseContext.currentPhase; - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - const phaseValidation = validateDelegationPromptForCurrentPhase({ - currentPhaseKey, - currentPhase, - nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), - workerName: name, - roleName: normalizedRole.length > 0 ? normalizedRole : null, - prompt, - validationContract: null, - validationHeuristics: "prompt_only", - }); - if (!phaseValidation.ok) { - return { ok: false, error: phaseValidation.error }; - } - - // Budget hard cap check - const budgetCheck = await checkBudgetHardCaps({ - failClosedOnTelemetryError: true, - operation: "delegate_to_subagent", - }); - if (budgetCheck.blocked) { - logger.warn("coordinator.delegate_to_subagent.hard_cap_blocked", { name, detail: budgetCheck.detail }); - return { - ok: false, - error: `Cannot delegate sub-agent: ${budgetCheck.detail}. Mission pausing.`, - hardCapTriggered: true, - hardCaps: budgetCheck.hardCaps, - }; - } - - const delegationContract = buildCoordinatorDelegationContract({ - graph: g, - workerId: `worker_${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_${Date.now()}`, - phaseKey: currentPhaseKey, - workerName: name, - intent: "subagent", - mode: "bounded_parallel", - scopeKind: "worker", - scopeKey: `worker:${parentWorkerId}:child:${name.trim().toLowerCase() || "child"}`, - scopeLabel: currentPhase?.name ?? parentWorkerId, - metadata: { - parentWorkerId, - }, - }); - const conflictingContract = hasConflictingDelegationContract({ - graph: g, - contract: delegationContract, - }); - if (conflictingContract) { - return { - ok: false, - error: - `Delegation scope '${delegationContract.scope.key}' is already owned by active ${conflictingContract.mode} delegation ` + - `(${conflictingContract.workerIntent}). Wait for it to finish before launching an overlapping child task.`, - }; - } - - // Create child step via spawnWorkerStep with parent linkage - const { workerId, step: newStep, roleName, modelId: spawnedModelId, toolProfile } = spawnWorkerStep({ - stepKey: delegationContract.activeWorkerIds[0], - name, - modelId: resolvedModelId, - prompt, - dependsOn: [parentWorkerId], - roleName: normalizedRole.length > 0 ? normalizedRole : null, - laneId: parentStep.laneId ?? null, - delegationContract, - }); - - // Attach parent linkage metadata to the new step - if (newStep) { - const finalizedDelegationContract = updateDelegationContract(delegationContract, { - activeWorkerIds: [workerId], - status: "active", - launchState: "waiting_on_worker", - }); - orchestratorService.updateStepMetadata({ - runId, - stepId: newStep.id, - metadata: { - parentWorkerId, - parentStepId: parentStep.id, - isSubAgent: true, - delegationContract: finalizedDelegationContract, - }, - }); - persistDelegationContract({ - contract: finalizedDelegationContract, - stepId: newStep.id, - reason: "delegation_contract_created", - action: "created", - }); - - onDagMutation({ - runId, - mutation: { type: "step_added", step: newStep }, - timestamp: nowIso(), - source: "coordinator", - }); - } - - // Trigger autopilot to pick up the new step - let launched = false; - let launchNote: string | undefined; - try { - const startedCount = await Promise.race([ - orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_delegate_subagent", - }), - new Promise<number>((_, reject) => - setTimeout(() => reject(new Error("autopilot_start_timeout")), AUTOPILOT_START_TIMEOUT_MS) - ), - ]); - if (newStep) { - const freshGraph = graph(); - const runningAttempt = freshGraph.attempts.find( - (a) => a.stepId === newStep.id && a.status === "running", - ); - launched = !!runningAttempt; - if (!launched && startedCount > 0) { - launchNote = "autopilot_started_other_steps"; - } else if (!launched) { - launchNote = "step_queued_not_yet_started"; - } - } else { - launched = startedCount > 0; - } - } catch { - launchNote = "autopilot_start_timeout_step_queued"; - logger.warn("coordinator.delegate_to_subagent.autopilot_timeout", { name, workerId }); - } - - trackTeamMember({ - workerId, - provider: resolvedProvider, - modelId: spawnedModelId, - role: roleName, - source: "ade-subagent", - isSubAgent: true, - parentWorkerId - }); - - logger.info("coordinator.delegate_to_subagent", { - name, - workerId, - parentWorkerId, - provider: resolvedProvider, - role: roleName, - launched, - launchNote, - }); - - return { - ok: true, - workerId, - parentWorkerId, - launched, - ...(launchNote ? { launchNote } : {}), - stepId: newStep?.id ?? null, - status: newStep?.status ?? "unknown", - name, - modelId: spawnedModelId, - provider: resolvedProvider, - role: roleName, - toolProfile, - delegationContract: updateDelegationContract(delegationContract, { - activeWorkerIds: [workerId], - status: "active", - launchState: "waiting_on_worker", - }), - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.delegate_to_subagent.error", { name, parentWorkerId, error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - const delegate_parallel = defineCoordinatorTool({ - description: - "Delegate multiple subtasks to child agents in a single atomic batch under one parent worker.", - inputSchema: z.object({ - parentWorkerId: z.string().describe("Step key of the parent worker that owns this subtask batch"), - tasks: z.array( - z.object({ - name: z.string().describe("Human-readable name for the sub-agent"), - prompt: z.string().describe("Full task prompt for the sub-agent"), - modelId: z.string().optional().describe("Optional model ID override for this sub-agent"), - role: z.string().optional().describe("Optional team role to bind (e.g. implementer, validator)"), - }) - ).min(1).max(32).describe("Batch of child tasks to spawn under the same parent worker"), - }), - execute: async ({ parentWorkerId, tasks }) => { - try { - const g = graph(); - const planningInputBlockReason = getPlanningInputBlockReason(g); - if (planningInputBlockReason) { - return { ok: false, error: planningInputBlockReason }; - } - const planningQuestionPolicyBlockReason = getPlanningQuestionPolicyBlockReason(g); - if (planningQuestionPolicyBlockReason) { - return { ok: false, error: planningQuestionPolicyBlockReason }; - } - const teamRuntime = resolveTeamRuntimeConfig(g); - - if (teamRuntime?.allowSubAgents === false) { - return { ok: false, error: "Sub-agent delegation is disabled (allowSubAgents=false). Use spawn_worker instead." }; - } - if (teamRuntime?.allowParallelAgents === false) { - return { ok: false, error: "Parallel agents disabled (allowParallelAgents=false). Batch delegation is not allowed." }; - } - - const parentStep = resolveStep(g, parentWorkerId); - if (!parentStep) { - return { ok: false, error: `Parent worker '${parentWorkerId}' not found.` }; - } - if (TERMINAL_STEP_STATUSES.has(parentStep.status)) { - return { - ok: false, - error: `Parent worker '${parentWorkerId}' is already ${parentStep.status}. Cannot delegate from a completed worker.`, - }; - } - - const budgetCheck = await checkBudgetHardCaps({ - failClosedOnTelemetryError: true, - operation: "delegate_parallel", - }); - if (budgetCheck.blocked) { - logger.warn("coordinator.delegate_parallel.hard_cap_blocked", { - parentWorkerId, - detail: budgetCheck.detail, - taskCount: tasks.length, - }); - return { - ok: false, - error: `Cannot delegate sub-agents: ${budgetCheck.detail}. Mission pausing.`, - hardCapTriggered: true, - hardCaps: budgetCheck.hardCaps, - }; - } - - const missionPhases = resolveMissionPhases(g); - const phaseContext = resolveConfiguredPhaseContext(g, missionPhases); - if (!phaseContext.ok) { - return { ok: false, error: phaseContext.error }; - } - const currentPhase = phaseContext.currentPhase; - const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; - const batchId = `delegation_batch_${Date.now()}`; - - const validatedTasks: Array<{ - name: string; - prompt: string; - normalizedRole: string | null; - roleName: string | null; - resolvedModelId: string; - provider: string; - toolProfile: Record<string, unknown> | null; - delegationContract: DelegationContract; - }> = []; - - for (let i = 0; i < tasks.length; i += 1) { - const rawTask = tasks[i]!; - const taskName = rawTask.name.trim(); - const taskPrompt = rawTask.prompt.trim(); - if (!taskName.length) { - return { ok: false, error: `tasks[${i}].name is required.` }; - } - 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: normalizedRole, - prompt: taskPrompt, - validationContract: null, - validationHeuristics: "prompt_only", - }); - if (!phaseValidation.ok) { - return { ok: false, error: phaseValidation.error }; - } - const roleDef = normalizedRole ? resolveRoleDefinition(teamRuntime, normalizedRole) : null; - if (normalizedRole && !roleDef) { - return { ok: false, error: `Unknown role '${normalizedRole}' in active team template.` }; - } - const spawnPolicy = authorizeWorkerSpawnPolicy({ - g, - requestedModelId: rawTask.modelId, - }); - if (!spawnPolicy.ok) { - if (spawnPolicy.blockedByPhaseOrdering) { - return phaseOrderingBlockedResult( - spawnPolicy.error, - spawnPolicy.phaseTransitionRequiredKey, - spawnPolicy.phaseTransitionRequiredReason, - ); - } - return { ok: false, error: spawnPolicy.error }; - } - const descriptor = resolveModelDescriptor(spawnPolicy.resolvedModelId); - if (!descriptor) { - return { ok: false, error: `Model '${spawnPolicy.resolvedModelId}' is not registered.` }; - } - const provider = spawnPolicy.resolvedProvider; - - if (provider === "claude" && descriptor.isCliWrapped && teamRuntime?.allowClaudeAgentTeams === false) { - return { - ok: false, - error: `Claude agent teams are disabled (allowClaudeAgentTeams=false). Cannot delegate claude sub-agent '${taskName}'.`, - }; - } - - const delegationContract = buildCoordinatorDelegationContract({ - graph: g, - workerId: `worker_${taskName.replace(/[^a-zA-Z0-9_-]/g, "_")}_${Date.now()}_${i + 1}`, - phaseKey: currentPhaseKey, - workerName: taskName, - intent: "parallel_subtasks", - mode: "bounded_parallel", - scopeKind: "batch", - scopeKey: `worker:${parentWorkerId}:batch:${batchId}:task:${taskName.trim().toLowerCase() || i + 1}`, - scopeLabel: currentPhase?.name ?? parentWorkerId, - batchId, - metadata: { - parentWorkerId, - taskIndex: i, - }, - }); - const conflictingContract = hasConflictingDelegationContract({ - graph: g, - contract: delegationContract, - }); - if (conflictingContract) { - return { - ok: false, - error: - `Delegation scope '${delegationContract.scope.key}' is already owned by active ${conflictingContract.mode} delegation ` + - `(${conflictingContract.workerIntent}). Wait for it to finish before launching an overlapping child task.`, - }; - } - - validatedTasks.push({ - name: taskName, - prompt: taskPrompt, - normalizedRole, - roleName: roleDef?.name ?? normalizedRole, - resolvedModelId: spawnPolicy.resolvedModelId, - provider, - toolProfile: normalizedRole ? resolveRoleToolProfile(teamRuntime, normalizedRole) : null, - delegationContract, - }); - } - - const createdChildren: Array<{ - workerId: string; - stepId: string | null; - status: string; - name: string; - modelId: string; - provider: string; - role: string | null; - toolProfile: Record<string, unknown> | null; - delegationContract: DelegationContract; - }> = []; - - for (const task of validatedTasks) { - const { workerId, step: childStep, roleName, modelId: spawnedModelId, toolProfile } = spawnWorkerStep({ - stepKey: task.delegationContract.activeWorkerIds[0], - name: task.name, - modelId: task.resolvedModelId, - prompt: task.prompt, - dependsOn: [parentWorkerId], - roleName: task.normalizedRole, - laneId: parentStep.laneId ?? null, - delegationContract: task.delegationContract, - }); - - if (childStep) { - const finalizedDelegationContract = updateDelegationContract(task.delegationContract, { - activeWorkerIds: [workerId], - status: "active", - launchState: "waiting_on_worker", - }); - orchestratorService.updateStepMetadata({ - runId, - stepId: childStep.id, - metadata: { - parentWorkerId, - parentStepId: parentStep.id, - isSubAgent: true, - delegationContract: finalizedDelegationContract, - }, - }); - persistDelegationContract({ - contract: finalizedDelegationContract, - stepId: childStep.id, - reason: "delegation_contract_created", - action: "created", - }); - - onDagMutation({ - runId, - mutation: { type: "step_added", step: childStep }, - timestamp: nowIso(), - source: "coordinator", - }); - } - - trackTeamMember({ - workerId, - provider: task.provider, - modelId: spawnedModelId, - role: roleName, - source: "ade-subagent", - isSubAgent: true, - parentWorkerId, - }); - - createdChildren.push({ - workerId, - stepId: childStep?.id ?? null, - status: childStep?.status ?? "unknown", - name: task.name, - modelId: spawnedModelId, - provider: task.provider, - role: roleName, - toolProfile: toolProfile ?? task.toolProfile, - delegationContract: updateDelegationContract(task.delegationContract, { - activeWorkerIds: [workerId], - status: "active", - launchState: "waiting_on_worker", - }), - }); - } - - let launchNote: string | undefined; - try { - await Promise.race([ - orchestratorService.startReadyAutopilotAttempts({ - runId, - reason: "coordinator_delegate_parallel", - }), - new Promise<number>((_, reject) => - setTimeout(() => reject(new Error("autopilot_start_timeout")), AUTOPILOT_START_TIMEOUT_MS) - ), - ]); - } catch { - launchNote = "autopilot_start_timeout_steps_queued"; - logger.warn("coordinator.delegate_parallel.autopilot_timeout", { - parentWorkerId, - taskCount: tasks.length, - }); - } - - const freshGraph = graph(); - const launchedCount = createdChildren.reduce((count, child) => { - if (!child.stepId) return count; - const hasRunningAttempt = freshGraph.attempts.some((attempt) => attempt.stepId === child.stepId && attempt.status === "running"); - return count + (hasRunningAttempt ? 1 : 0); - }, 0); - - logger.info("coordinator.delegate_parallel", { - batchId, - parentWorkerId, - taskCount: createdChildren.length, - launchedCount, - launchNote, - }); - - return { - ok: true, - batchId, - parentWorkerId, - total: createdChildren.length, - launchedCount, - pendingCount: Math.max(0, createdChildren.length - launchedCount), - ...(launchNote ? { launchNote } : {}), - children: createdChildren, - delegationContracts: createdChildren.map((child) => child.delegationContract), - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.delegate_parallel.error", { parentWorkerId, error: msg }); - return { ok: false, error: msg }; - } - } - }); - - const check_finalization_status = defineCoordinatorTool({ - description: - "Check the current finalization/queue-landing status for this mission. Returns contractSatisfied, executionComplete, queue landing state, closeout requirements, and any blockers. Use this before deciding to complete_mission when finalization is in progress, or after receiving a queue landing event.", - inputSchema: z.object({}), - execute: async () => { - try { - const state = await readMissionStateDocument({ - projectRoot, - runId, - }); - if (!state) { - return { - ok: true, - finalizationActive: false, - summary: "No mission state document found. Finalization has not been configured.", - }; - } - const fin = state.finalization; - if (!fin) { - return { - ok: true, - finalizationActive: false, - summary: "No finalization policy is active for this mission.", - }; - } - return { - ok: true, - finalizationActive: true, - status: fin.status, - executionComplete: fin.executionComplete, - contractSatisfied: fin.contractSatisfied, - blocked: fin.blocked, - blockedReason: fin.blockedReason, - waitReason: fin.waitReason, - summary: fin.summary, - detail: fin.detail, - prUrls: fin.prUrls, - mergeReadiness: fin.mergeReadiness, - requirements: fin.requirements, - warnings: fin.warnings, - updatedAt: fin.updatedAt, - completedAt: fin.completedAt, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error("coordinator.check_finalization_status.error", { error: msg }); - return { ok: false, error: msg }; - } - }, - }); - - return { - spawn_worker, - insert_milestone, - request_specialist, - delegate_to_subagent, - delegate_parallel, - stop_worker, - send_message, - message_worker, - broadcast, - get_worker_output, - list_workers, - report_status, - reflection_add, - report_result, - report_validation, - read_mission_status, - read_mission_state, - update_mission_state, - revise_plan, - update_tool_profiles, - transfer_lane, - provision_lane, - set_current_phase, - create_task, - update_task, - assign_task, - list_tasks, - skip_step, - mark_step_complete, - mark_step_failed, - retry_step, - complete_mission, - fail_mission, - get_budget_status, - ask_user, - request_user_input, - read_file, - read_step_output, - search_files, - get_project_context, - check_finalization_status, - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts deleted file mode 100644 index e9db32ae9..000000000 --- a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts +++ /dev/null @@ -1,479 +0,0 @@ -import type { - CoordinatorCapability, - DelegationContract, - DelegationContractStatus, - DelegationIntent, - DelegationLaunchState, - DelegationMode, - DelegationScope, - OrchestratorRunGraph, - OrchestratorStep, -} from "../../../shared/types"; -import { asRecord } from "./orchestratorContext"; -import { nowIso } from "../shared/utils"; - -type DelegationFailure = NonNullable<DelegationContract["failure"]>; -type DelegationFailureCategory = DelegationFailure["category"]; -type DelegationRecoveryOption = DelegationFailure["recoveryOptions"][number]; - -const PLANNER_STARTUP_OBSERVATION_TOOL_NAMES = new Set([ - "get_mission", - "get_run_graph", - "get_step_output", - "get_worker_states", - "get_timeline", - "get_worker_output", - "list_workers", - "read_mission_status", - "read_mission_state", - "read_step_output", - "stream_events", -]); - -const COORDINATOR_TOOL_CAPABILITIES: Array<{ - capability: CoordinatorCapability; - toolNames: string[]; -}> = [ - { capability: "fetch_project_context", toolNames: ["get_project_context"] }, - { capability: "spawn_top_level_worker", toolNames: ["spawn_worker", "request_specialist"] }, - { capability: "spawn_nested_worker", toolNames: ["delegate_to_subagent"] }, - { capability: "spawn_parallel_workers", toolNames: ["delegate_parallel"] }, - { capability: "observe", toolNames: [...PLANNER_STARTUP_OBSERVATION_TOOL_NAMES, "get_worker_output", "list_workers", "read_mission_status", "read_mission_state", "read_step_output"] }, - { capability: "read_repo", toolNames: ["read_file", "search_files"] }, - { capability: "message_workers", toolNames: ["send_message", "message_worker", "broadcast"] }, - { capability: "run_control", toolNames: ["stop_worker", "skip_step", "mark_step_complete", "mark_step_failed", "retry_step", "complete_mission", "fail_mission"] }, - { capability: "ask_user", toolNames: ["ask_user", "request_user_input"] }, - { capability: "update_mission_state", toolNames: ["update_mission_state", "report_status", "report_result", "report_validation", "revise_plan"] }, -]; - -export function normalizeCoordinatorToolName(toolName: string): string { - const trimmed = toolName.trim(); - const prefixPatterns = [ - /^ade[_:.](.+)$/i, - ]; - for (const pattern of prefixPatterns) { - const match = pattern.exec(trimmed); - if (match?.[1]) return match[1].trim(); - } - return trimmed; -} - -export function createDelegationScope(args: { - kind: DelegationScope["kind"]; - key: string; - label?: string | null; -}): DelegationScope { - return { - kind: args.kind, - key: args.key.trim(), - label: typeof args.label === "string" && args.label.trim().length > 0 ? args.label.trim() : null, - }; -} - -export function createDelegationContract(args: { - contractId: string; - runId: string; - workerIntent: DelegationIntent; - mode: DelegationMode; - scope: DelegationScope; - phaseKey?: string | null; - status?: DelegationContractStatus; - launchState?: DelegationLaunchState | null; - activeWorkerIds?: string[]; - coordinatorCapabilities?: CoordinatorCapability[]; - launchPolicy?: DelegationContract["launchPolicy"]; - failurePolicy?: DelegationContract["failurePolicy"]; - batchId?: string | null; - parentContractId?: string | null; - metadata?: Record<string, unknown> | null; - createdAt?: string; - updatedAt?: string; - startedAt?: string | null; - completedAt?: string | null; - failure?: DelegationContract["failure"] | null; -}): DelegationContract { - const createdAt = args.createdAt ?? nowIso(); - const updatedAt = args.updatedAt ?? createdAt; - const status = args.status ?? "launching"; - const contract: DelegationContract = { - schemaVersion: 1, - contractId: args.contractId.trim(), - runId: args.runId.trim(), - ownerKind: "coordinator", - workerIntent: args.workerIntent, - mode: args.mode, - scope: args.scope, - phaseKey: typeof args.phaseKey === "string" && args.phaseKey.trim().length > 0 ? args.phaseKey.trim() : null, - status, - launchState: args.launchState ?? null, - activeWorkerIds: Array.isArray(args.activeWorkerIds) - ? [...new Set(args.activeWorkerIds.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : [], - coordinatorCapabilities: args.coordinatorCapabilities ?? deriveCoordinatorCapabilities({ - mode: args.mode, - workerIntent: args.workerIntent, - status, - launchState: args.launchState ?? null, - }), - launchPolicy: args.launchPolicy ?? { maxLaunchAttempts: 1 }, - failurePolicy: args.failurePolicy ?? { retryLimit: 0, escalation: "intervention" }, - batchId: typeof args.batchId === "string" && args.batchId.trim().length > 0 ? args.batchId.trim() : null, - parentContractId: - typeof args.parentContractId === "string" && args.parentContractId.trim().length > 0 - ? args.parentContractId.trim() - : null, - failure: args.failure ?? null, - metadata: args.metadata ?? null, - createdAt, - updatedAt, - startedAt: args.startedAt ?? null, - completedAt: args.completedAt ?? null, - }; - return contract; -} - -export function updateDelegationContract( - contract: DelegationContract, - patch: Partial<Omit<DelegationContract, "schemaVersion" | "contractId" | "runId" | "ownerKind" | "scope" | "createdAt">>, -): DelegationContract { - const nextStatus = patch.status ?? contract.status; - const nextLaunchState = patch.launchState === undefined ? contract.launchState : patch.launchState ?? null; - const updatedAt = patch.updatedAt ?? nowIso(); - return { - ...contract, - ...patch, - status: nextStatus, - launchState: nextLaunchState, - updatedAt, - coordinatorCapabilities: patch.coordinatorCapabilities ?? deriveCoordinatorCapabilities({ - mode: patch.mode ?? contract.mode, - workerIntent: patch.workerIntent ?? contract.workerIntent, - status: nextStatus, - launchState: nextLaunchState, - }), - }; -} - -export function deriveCoordinatorCapabilities(args: { - mode: DelegationMode; - workerIntent: DelegationIntent; - status: DelegationContractStatus; - launchState?: DelegationLaunchState | null; -}): CoordinatorCapability[] { - if (args.mode === "bounded_parallel") { - return [ - "observe", - "read_repo", - "fetch_project_context", - "spawn_top_level_worker", - "spawn_nested_worker", - "spawn_parallel_workers", - "message_workers", - "ask_user", - "run_control", - "update_mission_state", - ]; - } - - if (args.mode === "recovery") { - return [ - "observe", - "spawn_top_level_worker", - "message_workers", - "run_control", - "update_mission_state", - ]; - } - - if (args.workerIntent === "planner") { - if (args.status === "active" || args.launchState === "waiting_on_worker") { - return ["observe", "run_control", "update_mission_state"]; - } - return ["observe", "fetch_project_context", "spawn_top_level_worker", "run_control", "update_mission_state"]; - } - - return ["observe", "run_control", "update_mission_state"]; -} - -export function getCoordinatorToolCapability(toolName: string): CoordinatorCapability | null { - const normalizedToolName = normalizeCoordinatorToolName(toolName); - for (const entry of COORDINATOR_TOOL_CAPABILITIES) { - if (entry.toolNames.includes(normalizedToolName)) return entry.capability; - } - return null; -} - -export function extractDelegationContract(value: unknown): DelegationContract | null { - const raw = asRecord(value); - if (!raw) return null; - if (Number(raw.schemaVersion) !== 1) return null; - const contractId = typeof raw.contractId === "string" ? raw.contractId.trim() : ""; - const runId = typeof raw.runId === "string" ? raw.runId.trim() : ""; - const ownerKind = raw.ownerKind === "coordinator" ? raw.ownerKind : null; - const workerIntent = - raw.workerIntent === "planner" - || raw.workerIntent === "implementation" - || raw.workerIntent === "validation" - || raw.workerIntent === "specialist" - || raw.workerIntent === "subagent" - || raw.workerIntent === "parallel_subtasks" - || raw.workerIntent === "recovery" - ? raw.workerIntent - : null; - const mode = raw.mode === "exclusive" || raw.mode === "bounded_parallel" || raw.mode === "recovery" - ? raw.mode - : null; - const status = - raw.status === "launching" - || raw.status === "active" - || raw.status === "completed" - || raw.status === "failed" - || raw.status === "launch_failed" - || raw.status === "recovering" - || raw.status === "blocked" - || raw.status === "canceled" - ? raw.status - : null; - const scopeRaw = asRecord(raw.scope); - const scopeKind = - scopeRaw?.kind === "phase" - || scopeRaw?.kind === "step" - || scopeRaw?.kind === "worker" - || scopeRaw?.kind === "batch" - ? scopeRaw.kind - : null; - const scopeKey = typeof scopeRaw?.key === "string" ? scopeRaw.key.trim() : ""; - if (!contractId || !runId || !ownerKind || !workerIntent || !mode || !status || !scopeKind || !scopeKey) { - return null; - } - const launchState = - raw.launchState === "awaiting_context" - || raw.launchState === "fetching_context" - || raw.launchState === "awaiting_worker_launch" - || raw.launchState === "launching_worker" - || raw.launchState === "waiting_on_worker" - || raw.launchState === "recovering" - || raw.launchState === "completed" - || raw.launchState === "blocked" - ? raw.launchState - : null; - const failureRaw = asRecord(raw.failure); - const failureCategory = - failureRaw?.category === "run_context_bug" - || failureRaw?.category === "provider_unreachable" - || failureRaw?.category === "permission_denied" - || failureRaw?.category === "tool_schema_error" - || failureRaw?.category === "native_tool_violation" - || failureRaw?.category === "unknown" - ? (failureRaw.category as DelegationFailureCategory) - : "unknown"; - const recoveryOptions = Array.isArray(failureRaw?.recoveryOptions) - ? failureRaw.recoveryOptions.filter( - (entry): entry is DelegationRecoveryOption => - entry === "retry" || entry === "switch_to_fallback_model" || entry === "cancel_run", - ) - : []; - return { - schemaVersion: 1, - contractId, - runId, - ownerKind, - workerIntent, - mode, - scope: { - kind: scopeKind, - key: scopeKey, - label: typeof scopeRaw?.label === "string" && scopeRaw.label.trim().length > 0 ? scopeRaw.label.trim() : null, - }, - phaseKey: typeof raw.phaseKey === "string" && raw.phaseKey.trim().length > 0 ? raw.phaseKey.trim() : null, - status, - launchState, - activeWorkerIds: Array.isArray(raw.activeWorkerIds) - ? [...new Set(raw.activeWorkerIds.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : [], - coordinatorCapabilities: Array.isArray(raw.coordinatorCapabilities) - ? raw.coordinatorCapabilities.filter((entry): entry is CoordinatorCapability => typeof entry === "string") - : deriveCoordinatorCapabilities({ mode, workerIntent, status, launchState }), - launchPolicy: asRecord(raw.launchPolicy) - ? { - maxLaunchAttempts: Math.max(1, Number(asRecord(raw.launchPolicy)?.maxLaunchAttempts ?? 1)), - } - : { maxLaunchAttempts: 1 }, - failurePolicy: asRecord(raw.failurePolicy) - ? { - retryLimit: Math.max(0, Number(asRecord(raw.failurePolicy)?.retryLimit ?? 0)), - escalation: - asRecord(raw.failurePolicy)?.escalation === "intervention" - || asRecord(raw.failurePolicy)?.escalation === "retry" - || asRecord(raw.failurePolicy)?.escalation === "stop" - ? (asRecord(raw.failurePolicy)?.escalation as DelegationContract["failurePolicy"]["escalation"]) - : "intervention", - } - : { retryLimit: 0, escalation: "intervention" }, - batchId: typeof raw.batchId === "string" && raw.batchId.trim().length > 0 ? raw.batchId.trim() : null, - parentContractId: - typeof raw.parentContractId === "string" && raw.parentContractId.trim().length > 0 - ? raw.parentContractId.trim() - : null, - failure: failureRaw - ? { - category: failureCategory, - reasonCode: typeof failureRaw.reasonCode === "string" ? failureRaw.reasonCode.trim() : "delegation_failure", - retryable: failureRaw.retryable === true, - recoveryOptions, - message: typeof failureRaw.message === "string" ? failureRaw.message.trim() : "", - toolName: typeof failureRaw.toolName === "string" && failureRaw.toolName.trim().length > 0 - ? failureRaw.toolName.trim() - : null, - retryCount: Math.max(0, Number(failureRaw.retryCount ?? 0)), - occurredAt: typeof failureRaw.occurredAt === "string" && failureRaw.occurredAt.trim().length > 0 - ? failureRaw.occurredAt.trim() - : nowIso(), - } - : null, - metadata: asRecord(raw.metadata) ?? null, - createdAt: typeof raw.createdAt === "string" && raw.createdAt.trim().length > 0 ? raw.createdAt.trim() : nowIso(), - updatedAt: typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt.trim() : nowIso(), - startedAt: typeof raw.startedAt === "string" && raw.startedAt.trim().length > 0 ? raw.startedAt.trim() : null, - completedAt: typeof raw.completedAt === "string" && raw.completedAt.trim().length > 0 ? raw.completedAt.trim() : null, - }; -} - -export function isDelegationContractTerminal(status: DelegationContractStatus): boolean { - return status === "completed" || status === "failed" || status === "launch_failed" || status === "blocked" || status === "canceled"; -} - -export function extractActiveDelegationContracts(graph: OrchestratorRunGraph): DelegationContract[] { - return graph.steps - .map((step) => extractDelegationContract(asRecord(step.metadata)?.delegationContract)) - .filter((contract): contract is DelegationContract => Boolean(contract)) - .map((contract) => { - const step = graph.steps.find((candidate) => - candidate.stepKey === contract.activeWorkerIds[0] || candidate.id === contract.activeWorkerIds[0], - ); - if (!step) return contract; - return deriveDelegationContractFromStep(step, contract); - }) - .filter((contract) => !isDelegationContractTerminal(contract.status)); -} - -export function deriveDelegationContractFromStep( - step: OrchestratorStep, - contract: DelegationContract, -): DelegationContract { - if (step.status === "running") { - return updateDelegationContract(contract, { - status: "active", - launchState: "waiting_on_worker", - startedAt: step.startedAt ?? contract.startedAt, - }); - } - if (step.status === "ready" || step.status === "pending") { - return contract; - } - if (step.status === "succeeded" || step.status === "skipped" || step.status === "superseded") { - return updateDelegationContract(contract, { - status: "completed", - launchState: "completed", - completedAt: step.completedAt ?? contract.completedAt ?? nowIso(), - }); - } - if (step.status === "blocked") { - return updateDelegationContract(contract, { - status: "blocked", - launchState: "blocked", - completedAt: step.completedAt ?? contract.completedAt ?? nowIso(), - }); - } - if (step.status === "canceled") { - return updateDelegationContract(contract, { - status: "canceled", - completedAt: step.completedAt ?? contract.completedAt ?? nowIso(), - }); - } - if (step.status === "failed") { - const nextStatus = contract.mode === "recovery" ? "blocked" : "failed"; - return updateDelegationContract(contract, { - status: nextStatus, - completedAt: step.completedAt ?? contract.completedAt ?? nowIso(), - }); - } - return contract; -} - -export function hasConflictingDelegationContract(args: { - graph: OrchestratorRunGraph; - contract: DelegationContract; -}): DelegationContract | null { - const activeContracts = extractActiveDelegationContracts(args.graph); - for (const existing of activeContracts) { - if (existing.contractId === args.contract.contractId) continue; - if (existing.scope.key !== args.contract.scope.key) continue; - if (existing.mode === "exclusive" || args.contract.mode === "exclusive") { - return existing; - } - } - return null; -} - -export function checkCoordinatorToolPermission(args: { - toolName: string; - contracts: DelegationContract[]; -}): { allowed: true } | { allowed: false; reason: string; contract: DelegationContract; capability: CoordinatorCapability | null } { - const normalizedToolName = normalizeCoordinatorToolName(args.toolName); - const capability = getCoordinatorToolCapability(normalizedToolName); - for (const contract of args.contracts) { - if (isDelegationContractTerminal(contract.status)) continue; - if (contract.mode === "exclusive" && contract.workerIntent === "planner") { - const allowedToolNames = - contract.status === "active" || contract.launchState === "waiting_on_worker" - ? PLANNER_STARTUP_OBSERVATION_TOOL_NAMES - : new Set([ - ...PLANNER_STARTUP_OBSERVATION_TOOL_NAMES, - "get_project_context", - "spawn_worker", - ]); - if (!allowedToolNames.has(normalizedToolName)) { - return { - allowed: false, - reason: - contract.status === "active" || contract.launchState === "waiting_on_worker" - ? `Planner delegation is active for scope '${contract.scope.key}'. Wait for the planner instead of using '${normalizedToolName}'.` - : `Planner delegation is launching for scope '${contract.scope.key}'. Stay inside startup-safe tools until the planner is running.`, - contract, - capability, - }; - } - continue; - } - if (contract.mode === "recovery" && capability === "read_repo") { - return { - allowed: false, - reason: - `Recovery delegation owns scope '${contract.scope.key}'. Launch recovery work or inspect worker output instead of resuming repo exploration directly.`, - contract, - capability, - }; - } - } - return { allowed: true }; -} - -export function derivePlanningStartupStateFromContract(contract: DelegationContract | null): { - state: "inactive" | "awaiting_project_context" | "awaiting_planner_launch" | "waiting_on_planner" | "failed"; - contract: DelegationContract | null; -} { - if (!contract || isDelegationContractTerminal(contract.status) && contract.status !== "blocked") { - return { state: "inactive", contract: null }; - } - if (contract.status === "launch_failed" || contract.status === "blocked" || contract.status === "failed") { - return { state: "failed", contract }; - } - if (contract.status === "active" || contract.launchState === "waiting_on_worker") { - return { state: "waiting_on_planner", contract }; - } - if (contract.launchState === "awaiting_context" || contract.launchState === "fetching_context") { - return { state: "awaiting_project_context", contract }; - } - return { state: "awaiting_planner_launch", contract }; -} diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts deleted file mode 100644 index 911a83157..000000000 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts +++ /dev/null @@ -1,1362 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - MissionExecutionPolicy, - MissionLevelSettings, - PhaseCard, - OrchestratorRun, - OrchestratorStep, - OrchestratorStepStatus, - OrchestratorAttempt, - OrchestratorClaim, - OrchestratorTeamRuntimeState, - OrchestratorWorkerRole, - RecoveryLoopPolicy, - RecoveryLoopState, - RecoveryLoopIteration -} from "../../../shared/types"; -import { - DEFAULT_EXECUTION_POLICY, - resolveExecutionPolicy, - evaluateRunCompletion, - evaluateRunCompletionFromPhases, - validateRunCompletion, - stepTypeToPhase, - phaseModelToExecutorKind, - roleForStepType, - validateRoleIsolation, - contextViewForRole, - evaluateRecoveryLoop, - buildExecutionPlanPreviewFromPhases -} from "./executionPolicy"; - -function makeStep(overrides: Partial<OrchestratorStep> & { id: string; status: OrchestratorStepStatus }): OrchestratorStep { - return { - runId: "run-1", - missionStepId: null, - stepKey: overrides.id, - title: overrides.title ?? "Step", - stepIndex: overrides.stepIndex ?? 0, - dependencyStepIds: [], - joinPolicy: "all_success", - quorumCount: null, - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - laneId: overrides.laneId ?? null, - metadata: overrides.metadata ?? null, - createdAt: "2026-02-20T00:00:00.000Z", - updatedAt: "2026-02-20T00:00:00.000Z", - startedAt: null, - completedAt: null, - ...overrides - }; -} - -describe("executionPolicy", () => { - describe("resolveExecutionPolicy", () => { - it("returns default when no sources provided", () => { - const policy = resolveExecutionPolicy({}); - expect(policy).toEqual(DEFAULT_EXECUTION_POLICY); - }); - - it("mission metadata takes highest precedence", () => { - const policy = resolveExecutionPolicy({ - missionMetadata: { testing: { mode: "tdd" } }, - projectConfig: { testing: { mode: "none" } } - }); - expect(policy.testing.mode).toBe("tdd"); - }); - - it("project config fills in when no mission-level override", () => { - const policy = resolveExecutionPolicy({ - projectConfig: { testing: { mode: "tdd" } } - }); - // Merge is always forced to "off" - expect(policy.merge.mode).toBe("off"); - expect(policy.testing.mode).toBe("tdd"); - // Other fields come from default - expect(policy.planning.mode).toBe("auto"); - }); - - it("partial mission metadata merges with defaults", () => { - const policy = resolveExecutionPolicy({ - missionMetadata: { planning: { mode: "manual_review" } } - }); - expect(policy.planning.mode).toBe("manual_review"); - expect(policy.implementation.model).toBe("openai/gpt-5.5"); // from default - }); - }); - - describe("stepTypeToPhase", () => { - it("maps step types to phases correctly", () => { - expect(stepTypeToPhase("analysis")).toBe("implementation"); - expect(stepTypeToPhase("code")).toBe("implementation"); - expect(stepTypeToPhase("implementation")).toBe("implementation"); - expect(stepTypeToPhase("test")).toBe("testing"); - expect(stepTypeToPhase("validation")).toBe("validation"); - expect(stepTypeToPhase("review")).toBe("codeReview"); - expect(stepTypeToPhase("integration")).toBe("integration"); - expect(stepTypeToPhase("merge")).toBeNull(); // merge phase removed — always off - expect(stepTypeToPhase("unknown")).toBeNull(); - }); - - it("falls back to taskType when stepType is empty", () => { - expect(stepTypeToPhase("", "analysis")).toBe("implementation"); - expect(stepTypeToPhase("", "review")).toBe("codeReview"); - }); - }); - - describe("phaseModelToExecutorKind", () => { - it("maps known model ids to opencode executor", () => { - expect(phaseModelToExecutorKind("opencode/anthropic/claude-sonnet-4-6")).toBe("opencode"); - }); - - it("maps CLI-wrapped models to their CLI executor", () => { - expect(phaseModelToExecutorKind("anthropic/claude-sonnet-4-6")).toBe("claude"); - }); - - it("maps alias and short model ids through the registry before falling back", () => { - expect(phaseModelToExecutorKind("gpt-5.5")).toBe("codex"); - expect(phaseModelToExecutorKind("gpt-5.3-codex-spark")).toBe("codex"); - }); - - it("falls back to opencode when model id is absent/unknown", () => { - expect(phaseModelToExecutorKind("legacy-provider-string")).toBe("opencode"); - expect(phaseModelToExecutorKind(undefined)).toBe("opencode"); - expect(phaseModelToExecutorKind(null)).toBe("opencode"); - }); - }); - - describe("evaluateRunCompletion", () => { - it("succeeds when all required phases are satisfied", () => { - const policy: MissionExecutionPolicy = { - planning: { mode: "auto" }, - implementation: { model: "codex" }, - testing: { mode: "post_implementation" }, - validation: { mode: "optional" }, - codeReview: { mode: "off" }, - testReview: { mode: "off" }, - prReview: { mode: "off" }, - merge: { mode: "off" } - }; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "analysis" } }), - makeStep({ id: "s2", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ id: "s3", status: "succeeded", metadata: { stepType: "test" } }) - ]; - const result = evaluateRunCompletion(steps, policy); - expect(result.status).toBe("succeeded"); - expect(result.completionReady).toBe(true); - }); - - it("returns succeeded when tests are disabled by policy", () => { - const policy: MissionExecutionPolicy = { - planning: { mode: "auto" }, - implementation: { model: "codex" }, - testing: { mode: "none" }, - validation: { mode: "off" }, - codeReview: { mode: "off" }, - testReview: { mode: "off" }, - prReview: { mode: "off" }, - merge: { mode: "off" } - }; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "analysis" } }), - makeStep({ id: "s2", status: "succeeded", metadata: { stepType: "code" } }) - ]; - const result = evaluateRunCompletion(steps, policy); - expect(result.status).toBe("succeeded"); - expect(result.completionReady).toBe(true); - }); - - it("treats missing required phases as advisory risk factors (coordinator is sole authority)", () => { - const policy: MissionExecutionPolicy = { - planning: { mode: "auto" }, - implementation: { model: "codex" }, - testing: { mode: "post_implementation" }, - validation: { mode: "required" }, - codeReview: { mode: "required" }, - testReview: { mode: "off" }, - prReview: { mode: "off" }, - merge: { mode: "off" } - }; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }) - ]; - const result = evaluateRunCompletion(steps, policy); - // Phase requirements are always advisory — coordinator decides completion - expect(result.diagnostics.some((d) => d.code === "phase_required_missing")).toBe(true); - expect(result.riskFactors.length).toBeGreaterThan(0); - }); - - it("returns active when a required phase is missing", () => { - const policy: MissionExecutionPolicy = { - planning: { mode: "off" }, - implementation: { model: "codex" }, - testing: { mode: "post_implementation" }, - validation: { mode: "off" }, - codeReview: { mode: "off" }, - testReview: { mode: "off" }, - prReview: { mode: "off" }, - merge: { mode: "off" } - }; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }) - ]; - const result = evaluateRunCompletion(steps, policy); - expect(result.status).toBe("active"); - expect(result.riskFactors).toContain("testing_required_but_missing"); - expect(result.completionReady).toBe(false); - }); - - it("merge phase is not evaluated (removed from execution phases)", () => { - const policy: MissionExecutionPolicy = { - ...DEFAULT_EXECUTION_POLICY, - merge: { mode: "off" } - }; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "analysis" } }), - makeStep({ id: "s2", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ id: "s3", status: "succeeded", metadata: { stepType: "test" } }) - ]; - const result = evaluateRunCompletion(steps, policy); - // Merge is no longer an execution phase — no diagnostic emitted for it - expect(result.diagnostics.some((d) => d.phase === "merge")).toBe(false); - expect(result.completionReady).toBe(true); - }); - - it("returns failed when required phase has failed steps", () => { - const policy: MissionExecutionPolicy = { - ...DEFAULT_EXECUTION_POLICY, - testing: { mode: "post_implementation" } - }; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ id: "s2", status: "failed", metadata: { stepType: "test" } }) - ]; - const result = evaluateRunCompletion(steps, policy); - expect(result.status).toBe("failed"); - expect(result.completionReady).toBe(true); - }); - - it("reports active when steps are still in progress", () => { - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ id: "s2", status: "running", metadata: { stepType: "test" } }) - ]; - const result = evaluateRunCompletion(steps, DEFAULT_EXECUTION_POLICY); - expect(result.status).toBe("active"); - expect(result.completionReady).toBe(false); - }); - }); -}); - -// ───────────────────────────────────────────────────── -// evaluateRunCompletionFromPhases (phase-card-based) -// ───────────────────────────────────────────────────── - -function makePhaseCard(overrides: Partial<Omit<PhaseCard, "phaseKey">> & { phaseKey: string }): PhaseCard { - return { - id: `card-${overrides.phaseKey}`, - name: overrides.phaseKey, - description: "", - instructions: "", - model: { provider: "codex", modelId: "gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-02-20T00:00:00.000Z", - updatedAt: "2026-02-20T00:00:00.000Z", - ...overrides, - phaseKey: overrides.phaseKey - }; -} - -describe("evaluateRunCompletionFromPhases", () => { - const defaultSettings: MissionLevelSettings = { - prStrategy: { kind: "manual" } - }; - - it("succeeds when all phase cards are satisfied", () => { - const phases = [ - makePhaseCard({ phaseKey: "development", validationGate: { tier: "none", required: false } }), - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), - makePhaseCard({ phaseKey: "testing", validationGate: { tier: "self", required: false } }) - ]; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "analysis" } }), - makeStep({ id: "s2", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ id: "s3", status: "succeeded", metadata: { stepType: "test" } }) - ]; - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - expect(result.status).toBe("succeeded"); - expect(result.completionReady).toBe(true); - }); - - it("does not require a second validation report for auto-spawned validator workers", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), - makePhaseCard({ phaseKey: "testing", validationGate: { tier: "dedicated", required: true } }), - makePhaseCard({ phaseKey: "validation", validationGate: { tier: "dedicated", required: true } }), - ]; - const steps = [ - makeStep({ id: "impl", status: "succeeded", metadata: { stepType: "code", phaseKey: "implementation" } }), - makeStep({ - id: "test", - status: "succeeded", - metadata: { - stepType: "test", - phaseKey: "testing", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Testing output must be validated.", - evidence: [], - }, - validationState: "pass", - validationPassedAt: "2026-03-02T00:10:00.000Z", - }, - }), - makeStep({ - id: "validate_test", - status: "succeeded", - metadata: { - stepType: "validation", - phaseKey: "validation", - autoSpawnedValidation: true, - targetStepKey: "test", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validate testing output.", - evidence: [], - }, - }, - }), - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - - expect(result.completionReady).toBe(true); - expect(result.diagnostics).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "required_validation_missing", - }), - ]) - ); - }); - - it("does not require a worker step for the closeout summary gate", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true }, position: 0 }), - makePhaseCard({ phaseKey: "validation", validationGate: { tier: "dedicated", required: true }, position: 1 }), - makePhaseCard({ - phaseKey: "closeout", - name: "Closeout", - validationGate: { tier: "self", required: true }, - position: 2, - }), - ]; - const steps = [ - makeStep({ id: "impl", status: "succeeded", metadata: { stepType: "code", phaseKey: "implementation" } }), - makeStep({ - id: "validation", - status: "succeeded", - metadata: { - stepType: "validation", - phaseKey: "validation", - validationState: "pass", - validationPassedAt: "2026-03-02T00:10:00.000Z", - }, - }), - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - - expect(result.completionReady).toBe(true); - expect(result.diagnostics).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "closeout", - code: "phase_required_missing", - }), - ]) - ); - }); - - it("treats legacy passed validation aliases as a passing required gate", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "self", required: true } }), - ]; - const steps = [ - makeStep({ - id: "impl", - status: "succeeded", - metadata: { - stepType: "code", - phaseKey: "implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Validation must pass.", - evidence: [], - }, - validationState: "passed", - lastValidationReport: { - verdict: "passed", - }, - }, - }), - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - - expect(result.completionReady).toBe(true); - expect(result.diagnostics).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ code: "required_validation_missing" }), - ]) - ); - }); - - it("maps customized built-in development phases to the implementation completion bucket", () => { - const phases = [ - makePhaseCard({ - phaseKey: "development", - name: "Parallel development", - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: true - }) - ]; - const steps = [ - makeStep({ - id: "s1", - status: "succeeded", - metadata: { stepType: "code", phaseKey: "development", phaseName: "Parallel development" } - }) - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - - expect(result.diagnostics).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "implementation", - code: "phase_required_missing" - }) - ]) - ); - expect(result.completionReady).toBe(true); - }); - - it("does not require the built-in implementation bucket for custom-only executable phase runs", () => { - const phases = [ - makePhaseCard({ - phaseKey: "parser_enrichment", - name: "Parser enrichment", - validationGate: { tier: "self", required: true }, - isBuiltIn: false, - isCustom: true - }), - makePhaseCard({ - phaseKey: "formatter_reporting", - name: "Formatter reporting", - validationGate: { tier: "self", required: true }, - isBuiltIn: false, - isCustom: true - }) - ]; - const steps = [ - makeStep({ - id: "s1", - status: "succeeded", - metadata: { - stepType: "implementation", - phaseKey: "parser_enrichment", - phaseName: "Parser enrichment", - validationState: "pass" - } - }), - makeStep({ - id: "s2", - status: "succeeded", - metadata: { - stepType: "implementation", - phaseKey: "formatter_reporting", - phaseName: "Formatter reporting", - validationState: "pass" - } - }) - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - - expect(result.diagnostics).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "implementation", - code: "phase_required_missing" - }) - ]) - ); - expect(result.completionReady).toBe(true); - expect(result.status).toBe("succeeded"); - }); - - it("counts succeeded display-only custom phase records as completion evidence", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), - makePhaseCard({ - phaseKey: "evidence_review", - name: "Evidence review", - validationGate: { tier: "self", required: true }, - isBuiltIn: false, - isCustom: true - }) - ]; - const steps = [ - makeStep({ - id: "impl", - status: "succeeded", - metadata: { stepType: "code", phaseKey: "implementation" } - }), - makeStep({ - id: "evidence", - status: "succeeded", - metadata: { - phaseKey: "evidence_review", - phaseName: "Evidence review", - stepType: "task", - taskType: "evidence_review", - isTask: true, - displayOnlyTask: true, - validationState: "pass" - } - }) - ]; - - 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: "evidence_review", - code: "phase_required_missing" - }) - ]) - ); - }); - - 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 } }), - makePhaseCard({ phaseKey: "testing", validationGate: { tier: "self", required: true } }) - ]; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }) - ]; - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - expect(result.status).toBe("active"); - expect(result.riskFactors).toContain("testing_required_but_missing"); - expect(result.completionReady).toBe(false); - }); - - it("reports failed when required phase has failed steps", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), - makePhaseCard({ phaseKey: "testing", validationGate: { tier: "dedicated", required: true } }) - ]; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ id: "s2", status: "failed", metadata: { stepType: "test" } }) - ]; - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - expect(result.status).toBe("failed"); - expect(result.completionReady).toBe(true); - }); - - it("reports active when steps are still in progress", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }) - ]; - const steps = [ - makeStep({ id: "s1", status: "running", metadata: { stepType: "code" } }) - ]; - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - expect(result.status).toBe("active"); - expect(result.completionReady).toBe(false); - }); - - it("phases not in the card array are marked as skipped", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }) - ]; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }) - ]; - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - // Testing phase not in cards, so it should be skipped not required - const testingDiag = result.diagnostics.find((d) => d.phase === "testing"); - expect(testingDiag?.code).toBe("phase_skipped_by_policy"); - }); - - it("treats required custom phases as first-class completion gates", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), - makePhaseCard({ - phaseKey: "security", - name: "Security Review", - validationGate: { tier: "dedicated", required: true }, - isBuiltIn: false, - isCustom: true - }) - ]; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }) - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - - expect(result.status).toBe("active"); - expect(result.completionReady).toBe(false); - expect(result.riskFactors).toContain("security_required_but_missing"); - expect(result.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "security", - code: "phase_required_missing", - blocking: true - }) - ]) - ); - }); - - it("keeps custom phases separate from built-in validation buckets", () => { - const phases = [ - makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), - makePhaseCard({ phaseKey: "validation", validationGate: { tier: "dedicated", required: true } }), - makePhaseCard({ - phaseKey: "release", - name: "Release Readiness", - validationGate: { tier: "dedicated", required: true }, - isBuiltIn: false, - isCustom: true - }) - ]; - const steps = [ - makeStep({ id: "s1", status: "succeeded", metadata: { stepType: "code" } }), - makeStep({ - id: "s2", - status: "succeeded", - metadata: { - stepType: "validation", - phaseKey: "release", - phaseName: "Release Readiness" - } - }) - ]; - - const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); - const validationDiag = result.diagnostics.find((d) => d.phase === "validation"); - const releaseDiag = result.diagnostics.find((d) => d.phase === "release"); - - expect(result.status).toBe("active"); - expect(result.completionReady).toBe(false); - expect(result.riskFactors).toContain("validation_required_but_missing"); - expect(validationDiag?.code).toBe("phase_required_missing"); - expect(releaseDiag?.code).toBe("phase_succeeded"); - }); -}); - -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 -// ───────────────────────────────────────────────────── - -function makeRun(overrides?: Partial<OrchestratorRun>): OrchestratorRun { - return { - id: "run-1", - missionId: "m-1", - projectId: "p-1", - status: "active", - contextProfile: "orchestrator_deterministic_v1", - schedulerState: "running", - createdAt: "2026-02-20T00:00:00.000Z", - updatedAt: "2026-02-20T00:00:00.000Z", - startedAt: "2026-02-20T00:00:00.000Z", - completedAt: null, - lastError: null, - metadata: null, - ...overrides - }; -} - -function makeAttempt(overrides?: Partial<OrchestratorAttempt>): OrchestratorAttempt { - return { - id: "att-1", - runId: "run-1", - stepId: "step-1", - attemptNumber: 1, - status: "succeeded", - executorKind: "opencode", - executorSessionId: null, - trackedSessionEnforced: false, - contextProfile: "orchestrator_deterministic_v1", - contextSnapshotId: null, - errorClass: "none", - errorMessage: null, - retryBackoffMs: 0, - createdAt: "2026-02-20T00:00:00.000Z", - startedAt: "2026-02-20T00:00:00.000Z", - completedAt: "2026-02-20T00:01:00.000Z", - resultEnvelope: null, - metadata: null, - ...overrides - }; -} - -function makeClaim(overrides?: Partial<OrchestratorClaim>): OrchestratorClaim { - return { - id: "claim-1", - runId: "run-1", - stepId: null, - attemptId: null, - ownerId: "worker-1", - scopeKind: "lane", - scopeValue: "lane-1", - state: "released", - acquiredAt: "2026-02-20T00:00:00.000Z", - heartbeatAt: "2026-02-20T00:00:00.000Z", - expiresAt: "2026-02-20T01:00:00.000Z", - releasedAt: "2026-02-20T00:01:00.000Z", - policy: null, - metadata: null, - ...overrides - }; -} - -function makeRunState(overrides?: Partial<OrchestratorTeamRuntimeState>): OrchestratorTeamRuntimeState { - return { - runId: "run-1", - phase: "executing", - completionRequested: true, - completionValidated: false, - lastValidationError: null, - coordinatorSessionId: "sess-1", - teammateIds: [], - createdAt: "2026-02-20T00:00:00.000Z", - updatedAt: "2026-02-20T00:00:00.000Z", - ...overrides - }; -} - -describe("validateRunCompletion", () => { - it("returns canComplete: true when all conditions are met", () => { - const run = makeRun(); - const steps = [makeStep({ id: "s1", status: "succeeded" })]; - const attempts = [makeAttempt({ status: "succeeded" })]; - const claims: OrchestratorClaim[] = []; - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, steps, attempts, claims, runState); - expect(result.canComplete).toBe(true); - expect(result.blockers).toHaveLength(0); - expect(result.validatedAt).toBeTruthy(); - }); - - it("blocks when attempts are still running", () => { - const run = makeRun(); - const steps = [makeStep({ id: "s1", status: "running" })]; - const attempts = [makeAttempt({ id: "att-1", status: "running" })]; - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, steps, attempts, [], runState); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "running_attempts")).toBe(true); - }); - - it("blocks when attempts are queued", () => { - const run = makeRun(); - const attempts = [makeAttempt({ id: "att-1", status: "queued" })]; - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, [], attempts, [], runState); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "running_attempts")).toBe(true); - }); - - it("blocks when steps are in claimed status", () => { - const run = makeRun(); - const steps = [makeStep({ id: "s1", status: "claimed" as OrchestratorStepStatus })]; - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, steps, [], [], runState); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "claimed_tasks")).toBe(true); - }); - - it("blocks when there are active task claims", () => { - const run = makeRun(); - const claims = [makeClaim({ state: "active", scopeKind: "task" })]; - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, [], [], claims, runState); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "claimed_tasks")).toBe(true); - }); - - it("does not block on active lane claims (only task claims)", () => { - const run = makeRun(); - const claims = [makeClaim({ state: "active", scopeKind: "lane" })]; - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, [], [], claims, runState); - expect(result.blockers.some((b) => b.code === "claimed_tasks")).toBe(false); - }); - - it("blocks when there are unresolved interventions", () => { - const run = makeRun(); - const runState = makeRunState({ completionRequested: true }); - const interventions = [{ status: "pending" }, { status: "resolved" }]; - - const result = validateRunCompletion(run, [], [], [], runState, interventions); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "unresolved_interventions")).toBe(true); - }); - - it("does not block when all interventions are resolved", () => { - const run = makeRun(); - const runState = makeRunState({ completionRequested: true }); - const interventions = [{ status: "resolved" }, { status: "resolved" }]; - - const result = validateRunCompletion(run, [], [], [], runState, interventions); - expect(result.blockers.some((b) => b.code === "unresolved_interventions")).toBe(false); - }); - - it("allows completion when completionRequested is false (coordinator is sole authority)", () => { - const run = makeRun(); - const runState = makeRunState({ completionRequested: false }); - - const result = validateRunCompletion(run, [], [], [], runState); - // completion_not_requested gate removed — calling finalizeRun IS the completion request - expect(result.canComplete).toBe(true); - expect(result.blockers.some((b) => b.code === "completion_not_requested")).toBe(false); - }); - - it("allows completion when runState is null (coordinator is sole authority)", () => { - const run = makeRun(); - - const result = validateRunCompletion(run, [], [], [], null); - // completion_not_requested gate removed — the coordinator decides - expect(result.canComplete).toBe(true); - expect(result.blockers.some((b) => b.code === "completion_not_requested")).toBe(false); - }); - - it("returns multiple blockers when multiple conditions fail", () => { - const run = makeRun(); - const attempts = [makeAttempt({ status: "running" })]; - const claims = [makeClaim({ state: "active", scopeKind: "task" })]; - const interventions = [{ status: "pending" }]; - - const result = validateRunCompletion(run, [], attempts, claims, null, interventions); - expect(result.canComplete).toBe(false); - expect(result.blockers.length).toBeGreaterThanOrEqual(2); - const codes = result.blockers.map((b) => b.code); - expect(codes).toContain("running_attempts"); - expect(codes).toContain("unresolved_interventions"); - // completion_not_requested gate removed — coordinator is sole authority - expect(codes).not.toContain("completion_not_requested"); - }); - - it("does not check interventions when not provided", () => { - const run = makeRun(); - const runState = makeRunState({ completionRequested: true }); - - const result = validateRunCompletion(run, [], [], [], runState); - expect(result.canComplete).toBe(true); - expect(result.blockers.some((b) => b.code === "unresolved_interventions")).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────── -// roleForStepType -// ───────────────────────────────────────────────────── - -describe("roleForStepType", () => { - it('maps "analysis" to "implementation"', () => { - expect(roleForStepType("analysis")).toBe("implementation"); - }); - - it('maps "code" to "implementation"', () => { - expect(roleForStepType("code")).toBe("implementation"); - }); - - it('maps "implementation" to "implementation"', () => { - expect(roleForStepType("implementation")).toBe("implementation"); - }); - - it('maps "test" to "testing"', () => { - expect(roleForStepType("test")).toBe("testing"); - }); - - it('maps "validation" to "testing"', () => { - expect(roleForStepType("validation")).toBe("testing"); - }); - - it('maps "review" to "code_review"', () => { - expect(roleForStepType("review")).toBe("code_review"); - }); - - it('maps "test_review" to "test_review"', () => { - expect(roleForStepType("test_review")).toBe("test_review"); - }); - - it('maps "review_test" to "test_review"', () => { - expect(roleForStepType("review_test")).toBe("test_review"); - }); - - it('maps "integration" to "integration"', () => { - expect(roleForStepType("integration")).toBe("integration"); - }); - - it('returns null for "merge" (merge phase removed)', () => { - expect(roleForStepType("merge")).toBeNull(); - }); - - it("returns null for unknown step types", () => { - expect(roleForStepType("unknown")).toBeNull(); - expect(roleForStepType("foobar")).toBeNull(); - }); - - it("uses taskType as fallback when stepType is empty", () => { - expect(roleForStepType("", "analysis")).toBe("implementation"); - expect(roleForStepType("", "code")).toBe("implementation"); - expect(roleForStepType("", "test")).toBe("testing"); - expect(roleForStepType("", "review")).toBe("code_review"); - expect(roleForStepType("", "test_review")).toBe("test_review"); - expect(roleForStepType("", "integration")).toBe("integration"); - expect(roleForStepType("", "merge")).toBeNull(); // merge phase removed - }); -}); - -// ───────────────────────────────────────────────────── -// validateRoleIsolation -// ───────────────────────────────────────────────────── - -describe("validateRoleIsolation", () => { - it("returns valid for a plan with separate workers per role", () => { - const steps = [ - { stepKey: "s1", role: "implementation" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "code_review" as OrchestratorWorkerRole, workerId: "w2" }, - { stepKey: "s3", role: "testing" as OrchestratorWorkerRole, workerId: "w3" } - ]; - const result = validateRoleIsolation(steps); - expect(result.valid).toBe(true); - expect(result.violations).toHaveLength(0); - }); - - it("detects implementation + code_review on same worker with auto_correct", () => { - const steps = [ - { stepKey: "s1", role: "implementation" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "code_review" as OrchestratorWorkerRole, workerId: "w1" } - ]; - const result = validateRoleIsolation(steps); - // auto_correct splits the conflict, so valid is true but violations recorded - expect(result.valid).toBe(true); - expect(result.violations.length).toBeGreaterThan(0); - expect(result.violations.some((v) => v.correctionApplied)).toBe(true); - expect(result.correctedPlan).toBe(true); - }); - - it("detects implementation + test_review on same worker", () => { - const steps = [ - { stepKey: "s1", role: "implementation" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "test_review" as OrchestratorWorkerRole, workerId: "w1" } - ]; - const result = validateRoleIsolation(steps); - expect(result.violations.length).toBeGreaterThan(0); - const violation = result.violations.find( - (v) => - v.rule.mutuallyExclusive.includes("implementation") && - v.rule.mutuallyExclusive.includes("test_review") - ); - expect(violation).toBeDefined(); - expect(violation!.correctionApplied).toBe(true); - }); - - it("detects testing + test_review on same worker", () => { - const steps = [ - { stepKey: "s1", role: "testing" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "test_review" as OrchestratorWorkerRole, workerId: "w1" } - ]; - const result = validateRoleIsolation(steps); - expect(result.violations.length).toBeGreaterThan(0); - const violation = result.violations.find( - (v) => - v.rule.mutuallyExclusive.includes("testing") && - v.rule.mutuallyExclusive.includes("test_review") - ); - expect(violation).toBeDefined(); - }); - - it("auto_correct splits conflicting steps into different workers", () => { - const steps = [ - { stepKey: "s1", role: "implementation" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "code_review" as OrchestratorWorkerRole, workerId: "w1" } - ]; - const result = validateRoleIsolation(steps); - expect(result.valid).toBe(true); - expect(result.correctedPlan).toBe(true); - // The violation detail should mention splitting into a new worker - const violation = result.violations[0]; - expect(violation.correctionApplied).toBe(true); - expect(violation.correctionDetail).toContain("Split"); - expect(violation.correctionDetail).toContain("w1"); - }); - - it("detects multiple violations at once", () => { - const steps = [ - { stepKey: "s1", role: "implementation" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "code_review" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s3", role: "testing" as OrchestratorWorkerRole, workerId: "w2" }, - { stepKey: "s4", role: "test_review" as OrchestratorWorkerRole, workerId: "w2" } - ]; - const result = validateRoleIsolation(steps); - // Should detect violations for both w1 and w2 - expect(result.violations.length).toBeGreaterThanOrEqual(2); - }); - - it("returns valid for an empty steps array", () => { - const result = validateRoleIsolation([]); - expect(result.valid).toBe(true); - expect(result.violations).toHaveLength(0); - }); - - it("uses reject enforcement when rule specifies it", () => { - const rejectRule = { - mutuallyExclusive: ["implementation", "code_review"] as [OrchestratorWorkerRole, OrchestratorWorkerRole], - enforcement: "reject" as const, - reason: "Hard rejection" - }; - const steps = [ - { stepKey: "s1", role: "implementation" as OrchestratorWorkerRole, workerId: "w1" }, - { stepKey: "s2", role: "code_review" as OrchestratorWorkerRole, workerId: "w1" } - ]; - const result = validateRoleIsolation(steps, [rejectRule]); - expect(result.valid).toBe(false); - expect(result.violations[0].correctionApplied).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────── -// contextViewForRole -// ───────────────────────────────────────────────────── - -describe("contextViewForRole", () => { - it('returns "implementation" for implementation role', () => { - expect(contextViewForRole("implementation")).toBe("implementation"); - }); - - it('returns "implementation" for planning role', () => { - expect(contextViewForRole("planning")).toBe("implementation"); - }); - - it('returns "review" for code_review role', () => { - expect(contextViewForRole("code_review")).toBe("review"); - }); - - it('returns "review" for test_review role', () => { - expect(contextViewForRole("test_review")).toBe("review"); - }); - - it('returns "implementation" for testing role', () => { - expect(contextViewForRole("testing")).toBe("implementation"); - }); - - it('returns "implementation" for integration role', () => { - expect(contextViewForRole("integration")).toBe("implementation"); - }); - -}); - -// ───────────────────────────────────────────────────── -// evaluateRecoveryLoop -// ───────────────────────────────────────────────────── - -describe("evaluateRecoveryLoop", () => { - function makeIteration(overrides: Partial<RecoveryLoopIteration> & { outcome: RecoveryLoopIteration["outcome"] }): RecoveryLoopIteration { - return { - iteration: overrides.iteration ?? 1, - triggerStepId: overrides.triggerStepId ?? "step-1", - triggerPhase: overrides.triggerPhase ?? "testing", - failureReason: overrides.failureReason ?? "test failed", - fixStepId: overrides.fixStepId ?? null, - reReviewStepId: overrides.reReviewStepId ?? null, - reTestStepId: overrides.reTestStepId ?? null, - outcome: overrides.outcome, - confidence: overrides.confidence, - startedAt: overrides.startedAt ?? "2026-02-20T00:00:00.000Z", - completedAt: overrides.completedAt ?? "2026-02-20T00:01:00.000Z" - }; - } - - const enabledPolicy: RecoveryLoopPolicy = { - enabled: true, - maxIterations: 3, - onExhaustion: "fail", - escalateAfterStagnant: 2 - }; - - it("should retry when under max iterations", () => { - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [makeIteration({ outcome: "still_failing", iteration: 1 })], - currentIteration: 1, - exhausted: false, - stopReason: null - }; - const result = evaluateRecoveryLoop(state, enabledPolicy); - expect(result.shouldRetry).toBe(true); - expect(result.action).toBe("fix"); - expect(result.reason).toContain("within policy bounds"); - }); - - it("should stop when max iterations reached", () => { - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [ - makeIteration({ outcome: "still_failing", iteration: 1 }), - makeIteration({ outcome: "still_failing", iteration: 2 }), - makeIteration({ outcome: "still_failing", iteration: 3 }) - ], - currentIteration: 3, - exhausted: false, - stopReason: null - }; - const result = evaluateRecoveryLoop(state, enabledPolicy); - expect(result.shouldRetry).toBe(false); - expect(result.action).toBe("stop"); - expect(result.reason).toContain("Max iterations"); - }); - - it("does not apply deterministic stagnation heuristics", () => { - const policy: RecoveryLoopPolicy = { - enabled: true, - maxIterations: 5, - onExhaustion: "intervention", - escalateAfterStagnant: 2 - }; - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [ - makeIteration({ outcome: "still_failing", iteration: 1 }), - makeIteration({ outcome: "still_failing", iteration: 2 }) - ], - currentIteration: 2, - exhausted: false, - stopReason: null - }; - const result = evaluateRecoveryLoop(state, policy); - expect(result.shouldRetry).toBe(true); - expect(result.action).toBe("fix"); - expect(result.reason).toContain("within policy bounds"); - }); - - it("disabled policy returns shouldRetry: false", () => { - const disabledPolicy: RecoveryLoopPolicy = { - enabled: false, - maxIterations: 3, - onExhaustion: "fail" - }; - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [], - currentIteration: 0, - exhausted: false, - stopReason: null - }; - const result = evaluateRecoveryLoop(state, disabledPolicy); - expect(result.shouldRetry).toBe(false); - expect(result.action).toBe("stop"); - expect(result.reason).toContain("disabled"); - }); - - it('returns "fix" action when retry is allowed', () => { - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [], - currentIteration: 0, - exhausted: false, - stopReason: null - }; - const result = evaluateRecoveryLoop(state, enabledPolicy); - expect(result.shouldRetry).toBe(true); - expect(result.action).toBe("fix"); - }); - - it("escalates when max iterations reached and onExhaustion is intervention", () => { - const policy: RecoveryLoopPolicy = { - enabled: true, - maxIterations: 2, - onExhaustion: "intervention" - }; - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [ - makeIteration({ outcome: "still_failing", iteration: 1 }), - makeIteration({ outcome: "still_failing", iteration: 2 }) - ], - currentIteration: 2, - exhausted: false, - stopReason: null - }; - const result = evaluateRecoveryLoop(state, policy); - expect(result.shouldRetry).toBe(false); - expect(result.action).toBe("escalate"); - expect(result.reason).toContain("Escalating"); - }); - - it("stops when exhausted flag is already set", () => { - const state: RecoveryLoopState = { - runId: "run-1", - iterations: [], - currentIteration: 1, - exhausted: true, - stopReason: "max iterations reached" - }; - const result = evaluateRecoveryLoop(state, enabledPolicy); - expect(result.shouldRetry).toBe(false); - expect(result.reason).toContain("exhausted"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts deleted file mode 100644 index 93a595d87..000000000 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts +++ /dev/null @@ -1,1400 +0,0 @@ -import type { - MissionExecutionPolicy, - MissionLevelSettings, - PhaseCard, - CompletionDiagnostic, - RunCompletionEvaluation, - RunCompletionValidation, - RunCompletionBlocker, - OrchestratorRun, - OrchestratorRunStatus, - OrchestratorStep, - OrchestratorAttempt, - OrchestratorClaim, - OrchestratorTeamRuntimeState, - OrchestratorWorkerRole, - OrchestratorExecutorKind, - RoleIsolationRule, - RoleIsolationValidation, - TeamManifest, - RecoveryLoopPolicy, - RecoveryLoopState, - OrchestratorContextView, - ExecutionPlanPreview, - ExecutionPlanPhase, - ExecutionPlanStepPreview -} from "../../../shared/types"; - -import { - DEFAULT_ROLE_ISOLATION_RULES, - DEFAULT_RECOVERY_LOOP_POLICY, - DEFAULT_INTEGRATION_PR_POLICY -} from "./orchestratorConstants"; - -import { getDefaultModelDescriptor, resolveModelDescriptor, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; -import { TERMINAL_STEP_STATUSES, filterExecutionSteps, isDisplayOnlyTaskStep } from "./orchestratorContext"; - -// ───────────────────────────────────────────────────── -// Default policy -// ───────────────────────────────────────────────────── - -const DEFAULT_CLAUDE_POLICY_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_POLICY_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; - -function isPassingValidationValue(value: unknown): boolean { - const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; - return normalized === "pass" || normalized === "passed" || normalized === "success" || normalized === "succeeded"; -} - -export const DEFAULT_EXECUTION_POLICY: MissionExecutionPolicy = { - planning: { mode: "auto", model: DEFAULT_CLAUDE_POLICY_MODEL_ID }, - implementation: { model: DEFAULT_CODEX_POLICY_MODEL_ID }, - testing: { mode: "post_implementation", model: DEFAULT_CODEX_POLICY_MODEL_ID }, - validation: { mode: "optional", model: DEFAULT_CODEX_POLICY_MODEL_ID }, - codeReview: { mode: "off" }, - testReview: { mode: "off" }, - prReview: { mode: "off" }, - merge: { mode: "off" }, - prStrategy: { kind: "manual" } -}; - -// ───────────────────────────────────────────────────── -// Resolution: merge partial policy with defaults -// ───────────────────────────────────────────────────── - -function mergePhase<T extends Record<string, unknown>>( - partial: Partial<T> | undefined, - defaults: T -): T { - if (!partial) return defaults; - return { ...defaults, ...partial }; -} - -export function resolveExecutionPolicy(sources: { - missionMetadata?: Partial<MissionExecutionPolicy> | null; - projectConfig?: Partial<MissionExecutionPolicy> | null; - fallback?: MissionExecutionPolicy; -}): MissionExecutionPolicy { - // Priority: mission metadata > project config > fallback > DEFAULT - const base = sources.fallback ?? DEFAULT_EXECUTION_POLICY; - - let resolved: MissionExecutionPolicy; - - if (sources.missionMetadata) { - const p = sources.missionMetadata; - resolved = { - planning: mergePhase(p.planning, base.planning), - implementation: mergePhase(p.implementation, base.implementation), - testing: mergePhase(p.testing, base.testing), - validation: mergePhase(p.validation, base.validation), - codeReview: mergePhase(p.codeReview, base.codeReview), - testReview: mergePhase(p.testReview, base.testReview), - prReview: mergePhase(p.prReview, base.prReview), - merge: base.merge, - prStrategy: p.prStrategy ?? base.prStrategy, - integrationPr: p.integrationPr ?? base.integrationPr, - teamRuntime: p.teamRuntime ?? base.teamRuntime - }; - } else if (sources.projectConfig) { - const p = sources.projectConfig; - resolved = { - planning: mergePhase(p.planning, base.planning), - implementation: mergePhase(p.implementation, base.implementation), - testing: mergePhase(p.testing, base.testing), - validation: mergePhase(p.validation, base.validation), - codeReview: mergePhase(p.codeReview, base.codeReview), - testReview: mergePhase(p.testReview, base.testReview), - prReview: mergePhase(p.prReview, base.prReview), - merge: base.merge, - prStrategy: p.prStrategy ?? base.prStrategy, - integrationPr: p.integrationPr ?? base.integrationPr, - teamRuntime: p.teamRuntime ?? base.teamRuntime - }; - } else { - resolved = base; - } - - return resolved; -} - -// ───────────────────────────────────────────────────── -// Phase ↔ step type mapping -// ───────────────────────────────────────────────────── - -export type ExecutionPhase = - | "implementation" - | "testing" - | "validation" - | "codeReview" - | "testReview" - | "integration"; - -export function stepTypeToPhase(stepType: string, taskType?: string): ExecutionPhase | null { - const primary = (stepType || "").trim().toLowerCase(); - const secondary = (taskType || "").trim().toLowerCase(); - if (primary === "test_review" || secondary === "test_review" || primary === "review_test" || secondary === "review_test") { - return "testReview"; - } - - if (primary === "analysis" || secondary === "analysis") return "implementation"; - if ( - primary === "code" || - primary === "implementation" || - primary === "development" || - secondary === "code" || - secondary === "implementation" || - secondary === "development" - ) return "implementation"; - if ( - primary === "test" || - primary === "testing" || - secondary === "test" || - secondary === "testing" - ) return "testing"; - if (primary === "validation" || secondary === "validation") return "validation"; - if (primary === "milestone" || secondary === "milestone") return "validation"; - if ((primary === "review" && (secondary === "test" || secondary === "validation")) || (secondary === "review" && primary === "test")) { - return "testReview"; - } - if (primary === "review" || secondary === "review") return "codeReview"; - if (primary === "integration" || secondary === "integration") return "integration"; - return null; -} - -function isAutoSpawnedValidationProofStep(step: OrchestratorStep): boolean { - return step.metadata?.autoSpawnedValidation === true; -} - -// ───────────────────────────────────────────────────── -// Completion evaluator -// ───────────────────────────────────────────────────── - -export function evaluateRunCompletion( - steps: OrchestratorStep[], - policy: MissionExecutionPolicy -): RunCompletionEvaluation { - const relevantSteps = filterExecutionSteps(steps); - const diagnostics: CompletionDiagnostic[] = []; - const riskFactors: string[] = []; - - // Map steps to phases - const phaseSteps = new Map<ExecutionPhase, OrchestratorStep[]>(); - for (const step of relevantSteps) { - const stepType = typeof step.metadata?.stepType === "string" ? step.metadata.stepType : ""; - const taskType = typeof step.metadata?.taskType === "string" ? step.metadata.taskType : ""; - const phaseKey = typeof step.metadata?.phaseKey === "string" ? step.metadata.phaseKey : ""; - const phaseName = typeof step.metadata?.phaseName === "string" ? step.metadata.phaseName : ""; - const phase = - phaseKeyToExecutionPhase(phaseKey) - ?? phaseKeyToExecutionPhase(phaseName) - ?? stepTypeToPhase(stepType, taskType); - if (phase) { - const bucket = phaseSteps.get(phase) ?? []; - bucket.push(step); - phaseSteps.set(phase, bucket); - } - } - - // Determine which phases are required for completion evaluation. - // Planning runs as an in-mission phase, but completion still hinges on delivery/quality phases. - const phaseRequired: Record<ExecutionPhase, boolean> = { - implementation: true, // always required - testing: policy.testing.mode !== "none", - validation: policy.validation.mode === "required", - codeReview: policy.codeReview.mode === "required", - testReview: policy.testReview.mode === "required", - integration: !!policy.integrationPr && hasMultipleLanes(relevantSteps) - }; - - const allPhases: ExecutionPhase[] = [ - "implementation", - "testing", - "validation", - "codeReview", - "testReview", - "integration" - ]; - - for (const phase of allPhases) { - const stepsInPhase = phaseSteps.get(phase) ?? []; - const required = phaseRequired[phase]; - - if (stepsInPhase.length === 0) { - if (required) { - diagnostics.push({ - phase, - code: "phase_required_missing", - message: `Required phase "${phase}" has no steps`, - blocking: true - }); - riskFactors.push(`${phase}_required_but_missing`); - } else { - diagnostics.push({ - phase, - code: "phase_skipped_by_policy", - message: `Phase "${phase}" skipped by policy`, - blocking: false - }); - } - continue; - } - - evaluatePhaseStepStatuses(phase, phase, required, stepsInPhase, diagnostics, riskFactors); - } - - const requiredValidationMissingStepKeys = relevantSteps - .filter((step) => step.status === "succeeded") - .filter((step) => !isAutoSpawnedValidationProofStep(step)) - .filter((step) => { - const meta = step.metadata ?? {}; - const validationContract = - typeof meta.validationContract === "object" && meta.validationContract - ? (meta.validationContract as Record<string, unknown>) - : null; - if (validationContract?.required !== true) return false; - - const lastValidationReport = - typeof meta.lastValidationReport === "object" && meta.lastValidationReport - ? (meta.lastValidationReport as Record<string, unknown>) - : null; - const lastVerdict = typeof lastValidationReport?.verdict === "string" ? lastValidationReport.verdict.toLowerCase() : ""; - const validationState = typeof meta.validationState === "string" ? meta.validationState.toLowerCase() : ""; - const validationPassedAt = typeof meta.validationPassedAt === "string" ? meta.validationPassedAt.trim() : ""; - const hasPassingValidation = isPassingValidationValue(lastVerdict) || isPassingValidationValue(validationState) || validationPassedAt.length > 0; - return !hasPassingValidation; - }) - .map((step) => step.stepKey); - - if (requiredValidationMissingStepKeys.length > 0) { - diagnostics.push({ - phase: "validation", - code: "required_validation_missing", - message: "Succeeded steps are missing a passing required validation contract.", - blocking: true, - details: { stepKeys: requiredValidationMissingStepKeys } - }); - riskFactors.push( - `required_validation_missing: ${requiredValidationMissingStepKeys.join(", ")}` - ); - } - - // Compute overall status - const hasBlockingDiagnostics = diagnostics.some((d) => d.blocking); - const anyPhaseFailed = diagnostics.some((d) => d.code === "phase_failed"); - const anyPhaseInProgress = diagnostics.some((d) => d.code === "phase_in_progress"); - - // Also check raw step statuses as a final status gate. - const allStepStatuses = relevantSteps.map((s) => s.status); - const allStepsTerminal = allStepStatuses.every((s) => TERMINAL_STEP_STATUSES.has(s)); - const anyStepBlocked = allStepStatuses.some((s) => s === "blocked"); - const anyStepRunning = allStepStatuses.some((s) => s === "running" || s === "ready" || s === "pending"); - - let status: OrchestratorRunStatus; - let completionReady: boolean; - - if (anyPhaseInProgress || anyStepRunning) { - status = "active"; - completionReady = false; - } else if (anyStepBlocked) { - status = "paused"; - completionReady = false; - } else if (anyPhaseFailed && hasBlockingDiagnostics) { - status = "failed"; - completionReady = true; - } else if (allStepsTerminal && !hasBlockingDiagnostics && riskFactors.length > 0) { - status = "succeeded"; - completionReady = true; - } else if (allStepsTerminal && !hasBlockingDiagnostics) { - const allSucceeded = allStepStatuses.every((s) => s === "succeeded" || s === "skipped" || s === "superseded"); - status = allSucceeded ? "succeeded" : "failed"; - completionReady = true; - } else { - status = "active"; - completionReady = false; - } - - return { status, diagnostics, riskFactors, completionReady }; -} - -function phaseHasConcreteSuccess(statuses: OrchestratorStep["status"][]): boolean { - return statuses.some((status) => status === "succeeded"); -} - -function phaseIsSuccessfulTerminal(statuses: OrchestratorStep["status"][]): boolean { - return statuses.every((status) => status === "succeeded" || status === "skipped" || status === "superseded"); -} - -function evaluatePhaseStepStatuses( - phase: string, - phaseLabel: string, - required: boolean, - steps: OrchestratorStep[], - diagnostics: CompletionDiagnostic[], - riskFactors: string[], -): void { - const statuses = steps.map((s) => s.status); - const allTerminal = statuses.every((s) => TERMINAL_STEP_STATUSES.has(s)); - const anyFailed = statuses.some((s) => s === "failed"); - const allSucceededOrSkipped = phaseIsSuccessfulTerminal(statuses); - const hasConcreteSuccess = phaseHasConcreteSuccess(statuses); - const anyBlocked = statuses.some((s) => s === "blocked"); - const anyInProgress = statuses.some((s) => s === "running" || s === "ready" || s === "pending"); - - if (allSucceededOrSkipped) { - if (required && !hasConcreteSuccess) { - diagnostics.push({ - phase, - code: "phase_completed_without_success", - message: `Required phase "${phaseLabel}" reached a terminal state without any successful work.`, - blocking: true - }); - riskFactors.push(`${phase}_completed_without_success`); - } else if (!required && !hasConcreteSuccess) { - diagnostics.push({ - phase, - code: "phase_terminal_without_success", - message: `Phase "${phaseLabel}" ended without a successful step.`, - blocking: false - }); - } else { - diagnostics.push({ - phase, - code: "phase_succeeded", - message: `Phase "${phaseLabel}" completed successfully`, - blocking: false - }); - } - } else if (anyFailed && allTerminal) { - const blocking = required; - diagnostics.push({ - phase, - code: "phase_failed", - message: `Phase "${phaseLabel}" has failed steps`, - blocking - }); - if (!blocking) { - riskFactors.push(`${phase}_failed`); - } - } else if (anyBlocked || anyInProgress) { - diagnostics.push({ - phase, - code: "phase_in_progress", - message: `Phase "${phaseLabel}" still in progress`, - blocking: true - }); - } -} - -// ───────────────────────────────────────────────────── -// Explicit completion validator -// ───────────────────────────────────────────────────── - -/** - * Validates whether a run can be finalized. This is the gate that - * the kernel's finalizeRun calls before transitioning a run to a - * terminal status. Returns structured blockers when the run cannot - * yet complete. - */ -export function validateRunCompletion( - _run: OrchestratorRun, - steps: OrchestratorStep[], - attempts: OrchestratorAttempt[], - claims: OrchestratorClaim[], - _runState: OrchestratorTeamRuntimeState | null, - interventions?: Array<{ status: string }> -): RunCompletionValidation { - const relevantSteps = filterExecutionSteps(steps); - const blockers: RunCompletionBlocker[] = []; - - // (a) No running or queued attempts - const activeAttempts = attempts.filter( - (a) => a.status === "running" || a.status === "queued" - ); - if (activeAttempts.length > 0) { - blockers.push({ - code: "running_attempts", - message: `${activeAttempts.length} attempt(s) still running or queued`, - detail: { attemptIds: activeAttempts.map((a) => a.id) } - }); - } - - // (b) No claimed-but-unstarted tasks - // "claimed" comes from OrchestratorTaskStatus (team runtime); cast for - // backward-compat with OrchestratorStepStatus which doesn't include it yet. - const claimedSteps = relevantSteps.filter((s) => (s.status as string) === "claimed"); - const activeTaskClaims = claims.filter( - (c) => c.state === "active" && c.scopeKind === "task" - ); - if (claimedSteps.length > 0 || activeTaskClaims.length > 0) { - blockers.push({ - code: "claimed_tasks", - message: `${claimedSteps.length} claimed step(s) and ${activeTaskClaims.length} active task claim(s) pending`, - detail: { - claimedStepIds: claimedSteps.map((s) => s.stepKey), - activeClaimIds: activeTaskClaims.map((c) => c.id) - } - }); - } - - // (c) No unresolved blocking interventions - if (interventions) { - const unresolved = interventions.filter((i) => i.status !== "resolved"); - if (unresolved.length > 0) { - blockers.push({ - code: "unresolved_interventions", - message: `${unresolved.length} unresolved intervention(s)`, - detail: { count: unresolved.length } - }); - } - } - - // (d) completion_not_requested gate removed — the act of calling - // finalizeRun or validateRunCompletion IS the completion request. - // The coordinator is the sole authority on when a mission is complete. - - return { - canComplete: blockers.length === 0, - blockers, - validatedAt: new Date().toISOString() - }; -} - -function hasMultipleLanes(steps: OrchestratorStep[]): boolean { - const laneIds = new Set<string>(); - for (const step of steps) { - if (step.laneId) laneIds.add(step.laneId); - } - return laneIds.size > 1; -} - -// ───────────────────────────────────────────────────── -// Model/executor helpers -// ───────────────────────────────────────────────────── - -export function phaseModelToExecutorKind(model?: string | null, fallback: OrchestratorExecutorKind = "opencode"): OrchestratorExecutorKind { - if (!model) return fallback; - const descriptor = resolveModelDescriptor(model); - if (descriptor) return resolveProviderGroupForModel(descriptor); - return fallback; -} - -// ───────────────────────────────────────────────────── -// Goal 1: Role isolation -// ───────────────────────────────────────────────────── - -/** - * Maps a step type (and optional task type) to an OrchestratorWorkerRole. - * Returns null if no mapping is found. - */ -export function roleForStepType( - stepType: string, - taskType?: string -): OrchestratorWorkerRole | null { - const primary = (stepType || "").trim().toLowerCase(); - const secondary = (taskType || "").trim().toLowerCase(); - - // Check compound types first (test_review / review_test) - if ( - primary === "test_review" || - primary === "review_test" || - secondary === "test_review" || - secondary === "review_test" - ) { - return "test_review"; - } - - if (primary === "analysis" || secondary === "analysis") return "implementation"; - if ( - primary === "code" || - primary === "implementation" || - primary === "development" || - secondary === "code" || - secondary === "implementation" || - secondary === "development" - ) { - return "implementation"; - } - if ( - primary === "test" || - primary === "testing" || - primary === "validation" || - secondary === "test" || - secondary === "testing" || - secondary === "validation" - ) { - return "testing"; - } - if (primary === "review" || secondary === "review") return "code_review"; - if (primary === "integration" || secondary === "integration") return "integration"; - return null; -} - -/** - * Validates that no worker holds conflicting roles as defined - * by the role isolation rules. When a rule's enforcement is - * "auto_correct", conflicting steps are split to new workers. - */ -export function validateRoleIsolation( - steps: Array<{ stepKey: string; role: OrchestratorWorkerRole; workerId?: string }>, - rules: RoleIsolationRule[] = DEFAULT_ROLE_ISOLATION_RULES -): RoleIsolationValidation { - // Build a mutable copy so we can assign workerIds during correction - const mutableSteps = steps.map((s) => ({ - stepKey: s.stepKey, - role: s.role, - workerId: s.workerId ?? "default-worker" - })); - - const violations: RoleIsolationValidation["violations"] = []; - let anyCorrected = false; - - for (const rule of rules) { - const [roleA, roleB] = rule.mutuallyExclusive; - - // Group steps by workerId, then check whether any worker holds both roles - const workerRoles = new Map<string, Array<{ stepKey: string; role: OrchestratorWorkerRole }>>(); - for (const step of mutableSteps) { - const bucket = workerRoles.get(step.workerId) ?? []; - bucket.push({ stepKey: step.stepKey, role: step.role }); - workerRoles.set(step.workerId, bucket); - } - - for (const [workerId, workerSteps] of workerRoles) { - const rolesInWorker = new Set(workerSteps.map((s) => s.role)); - if (rolesInWorker.has(roleA) && rolesInWorker.has(roleB)) { - // This worker has conflicting roles - const affectedStepIds = workerSteps - .filter((s) => s.role === roleA || s.role === roleB) - .map((s) => s.stepKey); - - if (rule.enforcement === "auto_correct") { - // Split: reassign the roleB steps to a new worker - const roleBSteps = workerSteps.filter((s) => s.role === roleB); - const newWorkerId = `${workerId}__isolated_${roleB}`; - for (const rbStep of roleBSteps) { - const mutable = mutableSteps.find((s) => s.stepKey === rbStep.stepKey); - if (mutable) { - mutable.workerId = newWorkerId; - } - } - violations.push({ - rule, - affectedStepIds, - correctionApplied: true, - correctionDetail: `Split ${roleB} steps from worker "${workerId}" into new worker "${newWorkerId}".` - }); - anyCorrected = true; - } else { - // reject — record the violation, do not correct - violations.push({ - rule, - affectedStepIds, - correctionApplied: false, - correctionDetail: `Worker "${workerId}" holds conflicting roles [${roleA}, ${roleB}]. Rejected.` - }); - } - } - } - } - - return { - valid: violations.length === 0 || violations.every((v) => v.correctionApplied), - violations, - correctedPlan: anyCorrected ? true : undefined - }; -} - -// ───────────────────────────────────────────────────── -// Context view for roles -// ───────────────────────────────────────────────────── - -/** - * Returns the context view appropriate for a given worker role. - * - * - implementation, planning, integration → "implementation" - * - code_review → "review" - * - test_review → "review" - * - testing → "implementation" - */ -export function contextViewForRole(role: OrchestratorWorkerRole): OrchestratorContextView { - switch (role) { - case "implementation": - case "planning": - case "integration": - case "testing": - return "implementation"; - case "code_review": - case "test_review": - return "review"; - default: { - // Exhaustiveness guard — should never reach here - const _exhaustive: never = role; - void _exhaustive; - return "implementation"; - } - } -} - -// ───────────────────────────────────────────────────── -// Recovery loop evaluator -// ───────────────────────────────────────────────────── - -/** - * Applies hard guardrails to recovery-loop progression. - * AI decides recovery action; this function only enforces policy bounds. - */ -export function evaluateRecoveryLoop( - state: RecoveryLoopState, - policy: RecoveryLoopPolicy -): { shouldRetry: boolean; reason: string; action: "fix" | "escalate" | "stop" } { - // If recovery loops are disabled in policy, always stop - if (!policy.enabled) { - return { shouldRetry: false, reason: "Recovery loops disabled by policy.", action: "stop" }; - } - - // If exhausted flag is already set - if (state.exhausted) { - return { - shouldRetry: false, - reason: `Recovery loop exhausted: ${state.stopReason ?? "max iterations reached"}.`, - action: policy.onExhaustion === "intervention" ? "escalate" : "stop" - }; - } - - // Hard limit on max iterations - if (state.currentIteration >= policy.maxIterations) { - if (policy.onExhaustion === "fail") { - return { - shouldRetry: false, - reason: `Max iterations (${policy.maxIterations}) reached. Failing.`, - action: "stop" - }; - } - if (policy.onExhaustion === "intervention") { - return { - shouldRetry: false, - reason: `Max iterations (${policy.maxIterations}) reached. Escalating for intervention.`, - action: "escalate" - }; - } - // complete_with_risk - return { - shouldRetry: false, - reason: `Max iterations (${policy.maxIterations}) reached. Completing with risk.`, - action: "stop" - }; - } - - // Guardrails permit another AI-directed recovery attempt. - return { - shouldRetry: true, - reason: `Recovery attempt ${state.currentIteration + 1} of ${policy.maxIterations} is within policy bounds.`, - action: "fix" - }; -} - -// ───────────────────────────────────────────────────── -// Execution plan preview builder -// ───────────────────────────────────────────────────── - -/** - * Builds an ExecutionPlanPreview for UI display from the - * resolved plan steps, policy, and team manifest. - */ -export function buildExecutionPlanPreview(args: { - runId: string; - missionId: string; - steps: Array<{ - stepKey: string; - title: string; - role: OrchestratorWorkerRole; - executorKind: string; - model: string; - laneId: string | null; - dependencies: string[]; - phase: string; - }>; - policy: MissionExecutionPolicy; - teamManifest: TeamManifest; -}): ExecutionPlanPreview { - const { runId, missionId, steps, policy, teamManifest } = args; - const now = new Date().toISOString(); - - // ── Group steps by phase ── - const phaseMap = new Map<string, typeof steps>(); - for (const step of steps) { - const bucket = phaseMap.get(step.phase) ?? []; - bucket.push(step); - phaseMap.set(step.phase, bucket); - } - - // ── Determine recovery policy ── - const recoveryPolicy: RecoveryLoopPolicy = - policy.recoveryLoop ?? DEFAULT_RECOVERY_LOOP_POLICY; - - // ── Build phase details ── - const phases: ExecutionPlanPhase[] = []; - const phaseOrder = [ - "implementation", - "testing", - "validation", - "codeReview", - "testReview", - "integration" - ]; - - for (const phaseName of phaseOrder) { - const phaseSteps = phaseMap.get(phaseName); - if (!phaseSteps || phaseSteps.length === 0) continue; - - // Determine gate policy string for this phase - let gatePolicy = "none"; - const phaseConfig = (policy as Record<string, any>)[phaseName]; - if (phaseConfig && typeof phaseConfig === "object" && "mode" in phaseConfig) { - gatePolicy = phaseConfig.mode; - } - - // Determine whether recovery is enabled for this phase - const recoveryPhases = new Set(["testing", "codeReview", "testReview", "validation"]); - const recoveryEnabled = recoveryPolicy.enabled && recoveryPhases.has(phaseName); - - const model = phaseSteps[0]?.model ?? "default"; - const executorKind = phaseSteps[0]?.executorKind ?? "opencode"; - - const stepPreviews: ExecutionPlanStepPreview[] = phaseSteps.map((step) => ({ - stepKey: step.stepKey, - title: step.title, - role: step.role, - executorKind: step.executorKind as ExecutionPlanStepPreview["executorKind"], - model: step.model, - laneId: step.laneId, - dependencies: step.dependencies, - gateType: recoveryPhases.has(phaseName) ? phaseName : null, - recoveryOnFailure: recoveryEnabled - })); - - phases.push({ - phase: phaseName, - enabled: true, - stepCount: phaseSteps.length, - steps: stepPreviews, - model, - executorKind: executorKind as ExecutionPlanPhase["executorKind"], - gatePolicy, - recoveryEnabled - }); - } - - // ── Team summary ── - const roles = [...new Set(teamManifest.workers.map((w) => w.role))]; - - // ── Integration PR plan ── - const integrationPrPlan = policy.integrationPr ?? DEFAULT_INTEGRATION_PR_POLICY; - - // ── Determine strategy label ── - const laneCount = teamManifest.parallelLanes.flat().length; - let strategy: string; - if (laneCount > 1) { - strategy = `Parallel execution across ${laneCount} lanes with ${teamManifest.workers.length} workers.`; - } else if (teamManifest.workers.length > 1) { - strategy = `Sequential multi-worker execution with ${teamManifest.workers.length} workers.`; - } else { - strategy = "Single-worker sequential execution."; - } - - return { - runId, - missionId, - generatedAt: now, - strategy, - phases, - teamSummary: { - workerCount: teamManifest.workers.length, - parallelLanes: teamManifest.parallelLanes.length, - roles - }, - recoveryPolicy, - integrationPrPlan, - aligned: true, - driftNotes: [] - }; -} - -// ───────────────────────────────────────────────────── -// Phase-card-based evaluation (replaces policy-based) -// ───────────────────────────────────────────────────── - -/** Maps a phaseKey from PhaseCard to an ExecutionPhase */ -function phaseKeyToExecutionPhase(phaseKey: string): ExecutionPhase | null { - const key = phaseKey.trim().toLowerCase(); - if (key === "planning" || key === "analysis") return null; - if ( - key === "implementation" - || key === "development" - || key === "code" - || key.startsWith("implementation_") - || key.startsWith("development_") - || key.endsWith("_implementation") - || key.endsWith("_development") - ) return "implementation"; - if (key === "testing" || key === "test") return "testing"; - if (key === "validation") return "validation"; - if (key === "code_review" || key === "codereview" || key === "review") return "codeReview"; - if (key === "test_review" || key === "testreview") return "testReview"; - if (key === "integration" || key === "merge" || key === "pr_conflict_resolution") return "integration"; - return null; -} - -type PhaseEvaluationTarget = { - key: string; - label: string; - enabled: boolean; - required: boolean; -}; - -function shouldCountDisplayOnlyPhaseRecordForCompletion( - step: OrchestratorStep, - configuredPhaseAliases: Map<string, string>, - phaseTargets: Map<string, PhaseEvaluationTarget> -): boolean { - if (!isDisplayOnlyTaskStep(step) || step.status !== "succeeded") return false; - - const meta = step.metadata ?? {}; - const explicitPhaseKey = normalizePhaseIdentity(typeof meta.phaseKey === "string" ? meta.phaseKey : ""); - const explicitPhaseName = normalizePhaseIdentity(typeof meta.phaseName === "string" ? meta.phaseName : ""); - const stepType = typeof meta.stepType === "string" ? meta.stepType : ""; - const taskType = typeof meta.taskType === "string" ? meta.taskType : ""; - const resolvedTargetKey = - configuredPhaseAliases.get(explicitPhaseKey) - ?? configuredPhaseAliases.get(explicitPhaseName) - ?? stepTypeToPhase(stepType, taskType); - - 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<string, unknown>) - : 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<string, unknown>) - : null; - const lastValidationReport = - typeof meta.lastValidationReport === "object" && meta.lastValidationReport - ? (meta.lastValidationReport as Record<string, unknown>) - : 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<string, unknown>).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(" ")); -} - -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", - "validation", - "codeReview", - "testReview", - "integration" -]; - -const BUILT_IN_PHASE_KEYS_FOR_COMPLETION = new Set([ - "planning", - "development", - "implementation", - "code", - "integration", - "testing", - "test", - "validation", - "code_review", - "codereview", - "review", - "test_review", - "testreview", - "merge", - "pr_conflict_resolution", - "closeout" -]); - -function normalizePhaseIdentity(value: string | null | undefined): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isNonExecutableCloseoutPhase(card: PhaseCard): boolean { - const key = normalizePhaseIdentity(card.phaseKey); - const name = normalizePhaseIdentity(card.name); - return key === "closeout" || name === "closeout"; -} - -function isCustomExecutableCompletionPhase(card: PhaseCard): boolean { - const key = normalizePhaseIdentity(card.phaseKey); - if (!key || card.isBuiltIn === true || card.isCustom !== true) return false; - if (isNonExecutableCloseoutPhase(card)) return false; - return !BUILT_IN_PHASE_KEYS_FOR_COMPLETION.has(key); -} - -/** - * Evaluates run completion using PhaseCard[] + MissionLevelSettings. - * Phase present in array = enabled. validationGate.required = required. - */ -export function evaluateRunCompletionFromPhases( - steps: OrchestratorStep[], - phases: PhaseCard[], - settings: MissionLevelSettings -): RunCompletionEvaluation { - const diagnostics: CompletionDiagnostic[] = []; - const riskFactors: string[] = []; - - const phaseTargets = new Map<string, PhaseEvaluationTarget>(); - const configuredPhaseAliases = new Map<string, string>(); - const customPhaseOrder: string[] = []; - let hasConfiguredImplementationEquivalent = false; - let hasCustomExecutablePhase = false; - - for (const phase of BUILT_IN_EXECUTION_PHASES) { - phaseTargets.set(phase, { - key: phase, - label: phase, - enabled: false, - required: false - }); - configuredPhaseAliases.set(phase, phase); - } - - for (const card of phases) { - const rawPhaseKey = typeof card.phaseKey === "string" ? card.phaseKey.trim() : ""; - const rawPhaseName = typeof card.name === "string" ? card.name.trim() : ""; - const normalizedPhaseKey = normalizePhaseIdentity(rawPhaseKey); - const normalizedPhaseName = normalizePhaseIdentity(rawPhaseName); - const builtInPhase = phaseKeyToExecutionPhase(rawPhaseKey); - const targetKey = builtInPhase ?? normalizedPhaseKey; - if (builtInPhase === "implementation") { - hasConfiguredImplementationEquivalent = true; - } else if (isCustomExecutableCompletionPhase(card)) { - hasCustomExecutablePhase = true; - } - - if (!targetKey) { - continue; - } - - let target = phaseTargets.get(targetKey); - if (!target) { - target = { - key: targetKey, - label: rawPhaseName || rawPhaseKey || targetKey, - enabled: false, - required: false - }; - phaseTargets.set(targetKey, target); - customPhaseOrder.push(targetKey); - } - - target.enabled = true; - if ((rawPhaseName || rawPhaseKey) && target.label === target.key) { - target.label = rawPhaseName || rawPhaseKey; - } - if (card.validationGate.required && !isNonExecutableCloseoutPhase(card)) { - target.required = true; - } - - if (normalizedPhaseKey) { - configuredPhaseAliases.set(normalizedPhaseKey, targetKey); - } - if (normalizedPhaseName) { - configuredPhaseAliases.set(normalizedPhaseName, targetKey); - } - } - - const implementationTarget = phaseTargets.get("implementation"); - const requireImplementationBucket = hasConfiguredImplementationEquivalent || !hasCustomExecutablePhase; - if (implementationTarget && requireImplementationBucket) { - implementationTarget.enabled = true; - implementationTarget.required = true; - } - - const executionSteps = filterExecutionSteps(steps); - const relevantSteps = [ - ...executionSteps, - ...steps.filter((step) => - shouldCountDisplayOnlyPhaseRecordForCompletion(step, configuredPhaseAliases, phaseTargets) - ) - ]; - - if (settings.integrationPr && hasMultipleLanes(executionSteps)) { - const integrationTarget = phaseTargets.get("integration"); - if (integrationTarget) { - integrationTarget.enabled = true; - } - } - - const phaseSteps = new Map<string, OrchestratorStep[]>(); - for (const step of relevantSteps) { - const meta = step.metadata ?? {}; - const explicitPhaseKey = normalizePhaseIdentity(typeof meta.phaseKey === "string" ? meta.phaseKey : ""); - const explicitPhaseName = normalizePhaseIdentity(typeof meta.phaseName === "string" ? meta.phaseName : ""); - const stepType = typeof meta.stepType === "string" ? meta.stepType : ""; - const taskType = typeof meta.taskType === "string" ? meta.taskType : ""; - - const resolvedTargetKey = - configuredPhaseAliases.get(explicitPhaseKey) - ?? configuredPhaseAliases.get(explicitPhaseName) - ?? stepTypeToPhase(stepType, taskType); - - if (!resolvedTargetKey || !phaseTargets.has(resolvedTargetKey)) { - continue; - } - - const bucket = phaseSteps.get(resolvedTargetKey) ?? []; - bucket.push(step); - 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) { - const target = phaseTargets.get(phase); - if (!target) { - continue; - } - - const stepsInPhase = phaseSteps.get(phase) ?? []; - const { enabled, label: phaseLabel, required } = target; - - if (stepsInPhase.length === 0) { - if (required) { - diagnostics.push({ - phase, - code: "phase_required_missing", - message: `Required phase "${phaseLabel}" has no steps`, - blocking: true - }); - riskFactors.push(`${phase}_required_but_missing`); - } else if (!enabled) { - diagnostics.push({ - phase, - code: "phase_skipped_by_policy", - message: `Phase "${phaseLabel}" not included in phase cards`, - blocking: false - }); - } - continue; - } - - evaluatePhaseStepStatuses(phase, phaseLabel, required, stepsInPhase, diagnostics, riskFactors); - } - - // Check validation contracts - const requiredValidationMissingStepKeys = relevantSteps - .filter((step) => step.status === "succeeded") - .filter((step) => !isAutoSpawnedValidationProofStep(step)) - .filter((step) => { - const meta = step.metadata ?? {}; - const validationContract = - typeof meta.validationContract === "object" && meta.validationContract - ? (meta.validationContract as Record<string, unknown>) - : null; - if (validationContract?.required !== true) return false; - const lastValidationReport = - typeof meta.lastValidationReport === "object" && meta.lastValidationReport - ? (meta.lastValidationReport as Record<string, unknown>) - : null; - const lastVerdict = typeof lastValidationReport?.verdict === "string" ? lastValidationReport.verdict.toLowerCase() : ""; - const validationState = typeof meta.validationState === "string" ? meta.validationState.toLowerCase() : ""; - const validationPassedAt = typeof meta.validationPassedAt === "string" ? meta.validationPassedAt.trim() : ""; - return !(isPassingValidationValue(lastVerdict) || isPassingValidationValue(validationState) || validationPassedAt.length > 0); - }) - .map((step) => step.stepKey); - - if (requiredValidationMissingStepKeys.length > 0) { - diagnostics.push({ - phase: "validation", - code: "required_validation_missing", - message: "Succeeded steps are missing a passing required validation contract.", - blocking: true, - details: { stepKeys: requiredValidationMissingStepKeys } - }); - riskFactors.push(`required_validation_missing: ${requiredValidationMissingStepKeys.join(", ")}`); - } - - // Compute overall status - const hasBlockingDiagnostics = diagnostics.some((d) => d.blocking); - const anyPhaseFailed = diagnostics.some((d) => d.code === "phase_failed"); - const anyPhaseInProgress = diagnostics.some((d) => d.code === "phase_in_progress"); - - const allStepStatuses = relevantSteps.map((s) => s.status); - const allStepsTerminal = allStepStatuses.every((s) => TERMINAL_STEP_STATUSES.has(s)); - const anyStepBlocked = allStepStatuses.some((s) => s === "blocked"); - const anyStepRunning = allStepStatuses.some((s) => s === "running" || s === "ready" || s === "pending"); - - let status: OrchestratorRunStatus; - let completionReady: boolean; - - if (anyPhaseInProgress || anyStepRunning) { - status = "active"; - completionReady = false; - } else if (anyStepBlocked) { - status = "paused"; - completionReady = false; - } else if (anyPhaseFailed && hasBlockingDiagnostics) { - status = "failed"; - completionReady = true; - } else if (allStepsTerminal && !hasBlockingDiagnostics && riskFactors.length > 0) { - status = "succeeded"; - completionReady = true; - } else if (allStepsTerminal && !hasBlockingDiagnostics) { - const allSucceeded = allStepStatuses.every((s) => s === "succeeded" || s === "skipped" || s === "superseded"); - status = allSucceeded ? "succeeded" : "failed"; - completionReady = true; - } else { - status = "active"; - completionReady = false; - } - - return { status, diagnostics, riskFactors, completionReady }; -} - -/** - * Builds an ExecutionPlanPreview from phase cards instead of policy. - */ -export function buildExecutionPlanPreviewFromPhases(args: { - runId: string; - missionId: string; - steps: Array<{ - stepKey: string; - title: string; - role: OrchestratorWorkerRole; - executorKind: string; - model: string; - laneId: string | null; - dependencies: string[]; - phase: string; - }>; - phases: PhaseCard[]; - settings: MissionLevelSettings; - teamManifest: TeamManifest; -}): ExecutionPlanPreview { - const { runId, missionId, steps, phases, settings, teamManifest } = args; - const now = new Date().toISOString(); - - // Group steps by phase - const phaseMap = new Map<string, typeof steps>(); - for (const step of steps) { - const bucket = phaseMap.get(step.phase) ?? []; - bucket.push(step); - phaseMap.set(step.phase, bucket); - } - - // Determine recovery policy - const recoveryPolicy: RecoveryLoopPolicy = - settings.recoveryLoop ?? DEFAULT_RECOVERY_LOOP_POLICY; - - // Build phase details from phase cards, preserving custom workflow phases. - const phaseDetails: ExecutionPlanPhase[] = []; - const phaseOrder: Array<{ phaseName: string; card: PhaseCard | null }> = []; - const seenPhases = new Set<string>(); - 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()) { - const normalizedPhaseName = normalizeSearchableText(phaseName); - if (normalizedPhaseName === "planning" || normalizedPhaseName === "analysis" || seenPhases.has(phaseName)) continue; - seenPhases.add(phaseName); - phaseOrder.push({ phaseName, card: null }); - } - - for (const { phaseName, card } of phaseOrder) { - const phaseSteps = phaseMap.get(phaseName); - if (!phaseSteps || phaseSteps.length === 0) continue; - - const gatePolicy = card?.validationGate.required ? "required" : (card?.validationGate.tier ?? "none"); - - const recoveryPhases = new Set(["testing", "codeReview", "testReview", "validation"]); - const recoveryEnabled = recoveryPolicy.enabled && recoveryPhases.has(phaseName); - - const model = phaseSteps[0]?.model ?? "default"; - const executorKind = phaseSteps[0]?.executorKind ?? "opencode"; - - const stepPreviews: ExecutionPlanStepPreview[] = phaseSteps.map((step) => ({ - stepKey: step.stepKey, - title: step.title, - role: step.role, - executorKind: step.executorKind as ExecutionPlanStepPreview["executorKind"], - model: step.model, - laneId: step.laneId, - dependencies: step.dependencies, - gateType: recoveryPhases.has(phaseName) ? phaseName : null, - recoveryOnFailure: recoveryEnabled - })); - - phaseDetails.push({ - phase: phaseName, - enabled: true, - stepCount: phaseSteps.length, - steps: stepPreviews, - model, - executorKind: executorKind as ExecutionPlanPhase["executorKind"], - gatePolicy, - recoveryEnabled - }); - } - - // Team summary - const roles = [...new Set(teamManifest.workers.map((w) => w.role))]; - - // Integration PR plan - const integrationPrPlan = settings.integrationPr ?? DEFAULT_INTEGRATION_PR_POLICY; - - // Strategy label - const laneCount = teamManifest.parallelLanes.flat().length; - let strategy: string; - if (laneCount > 1) { - strategy = `Parallel execution across ${laneCount} lanes with ${teamManifest.workers.length} workers.`; - } else if (teamManifest.workers.length > 1) { - strategy = `Sequential multi-worker execution with ${teamManifest.workers.length} workers.`; - } else { - strategy = "Single-worker sequential execution."; - } - - return { - runId, - missionId, - generatedAt: now, - strategy, - phases: phaseDetails, - teamSummary: { - workerCount: teamManifest.workers.length, - parallelLanes: teamManifest.parallelLanes.length, - roles - }, - recoveryPolicy, - integrationPrPlan, - aligned: true, - driftNotes: [] - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/metaReasoner.ts b/apps/desktop/src/main/services/orchestrator/metaReasoner.ts deleted file mode 100644 index 6d366fd8b..000000000 --- a/apps/desktop/src/main/services/orchestrator/metaReasoner.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { FanOutDecision } from "../../../shared/types"; - -// --------------------------------------------------------------------------- -// Meta-reasoner: AI-powered analysis of agent output to decide fan-out strategy -// --------------------------------------------------------------------------- - -export type MetaReasonerRunState = { - activeAgentCount: number; - parallelismCap: number; - availableLanes: string[]; - fileOwnershipMap: Record<string, string>; -}; - -export type MetaReasonerOpts = { - stepOutput: string; - stepKey: string; - runState: MetaReasonerRunState; - aiService: { - executeTask: (args: { - feature: "orchestrator"; - taskType: "review"; - prompt: string; - cwd: string; - provider: "claude" | "codex"; - reasoningEffort?: string; - permissionMode: "read-only"; - oneShot: true; - timeoutMs?: number; - }) => Promise<{ text: string; sessionId: string | null }>; - }; - cwd: string; -}; - -const INLINE_FALLBACK: FanOutDecision = { - strategy: "inline", - subtasks: [], - reasoning: "fallback — no subtasks detected or parse failed" -}; - -/** - * Analyze agent output and decide whether & how to fan out subtasks. - * Always falls back to "inline" (no fan-out) if the AI call fails or - * produces unparseable output. - */ -export async function analyzeForFanOut(opts: MetaReasonerOpts): Promise<FanOutDecision> { - try { - const prompt = buildMetaReasonerPrompt(opts); - const result = await opts.aiService.executeTask({ - feature: "orchestrator" as const, - taskType: "review" as const, - prompt, - cwd: opts.cwd, - provider: "claude", - reasoningEffort: "low", - permissionMode: "read-only" as const, - oneShot: true, - timeoutMs: 30_000 - }); - if (!result.text?.trim()) return INLINE_FALLBACK; - const decision = parseMetaReasonerOutput(result.text); - // Safety cap: never exceed maxChildren - if (decision.subtasks.length > 8) { - decision.subtasks = decision.subtasks.slice(0, 8); - decision.reasoning += " (capped at 8 subtasks)"; - } - return decision; - } catch { - return INLINE_FALLBACK; - } -} - -function buildMetaReasonerPrompt(opts: MetaReasonerOpts): string { - const claimedFiles = Object.entries(opts.runState.fileOwnershipMap); - const claimedSection = claimedFiles.length > 0 - ? claimedFiles.map(([f, s]) => `${f} (${s})`).join(", ") - : "none"; - - return `You are a meta-reasoner for a multi-agent orchestrator. Analyze the following agent output and decide the optimal dispatch strategy for any subtasks. - -## Agent Output (step: ${opts.stepKey}) -${opts.stepOutput.slice(0, 4000)} - -## Current Run State -- Active agents: ${opts.runState.activeAgentCount} -- Parallelism cap: ${opts.runState.parallelismCap} -- Available lanes: ${opts.runState.availableLanes.join(", ") || "none"} -- Files already claimed: ${claimedSection} - -## Decision Factors -| Factor | Favors Internal | Favors External | -|--------|----------------|-----------------| -| Subtask count <= 3 | yes | | -| Subtask count > 6 | | yes | -| File overlap between subtasks | yes (sequential) | | -| No file overlap | | yes (parallel) | -| Simple/trivial complexity | yes | | -| Complex subtasks | | yes | -| Near parallelism cap | yes | | - -## Instructions -1. Identify any distinct subtasks from the agent output. -2. If none, return strategy "inline" with empty subtasks. -3. Otherwise decide the best dispatch strategy. -4. For "hybrid", group related subtasks into clusters. - -Respond ONLY with a JSON object (no markdown fences) matching this schema: -{ - "strategy": "inline" | "internal_parallel" | "external_parallel" | "hybrid", - "subtasks": [{ "title": string, "instructions": string, "files": string[], "complexity": "trivial"|"simple"|"moderate"|"complex" }], - "reasoning": string, - "clusters": [{ "subtaskIndices": number[], "reason": string }] -}`; -} - -function parseMetaReasonerOutput(text: string): FanOutDecision { - try { - // Try to extract JSON from the response — handle both raw JSON and markdown-fenced - let jsonStr = text.trim(); - const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); - if (fenceMatch) { - jsonStr = fenceMatch[1].trim(); - } - const parsed = JSON.parse(jsonStr); - // Validate required fields - const validStrategies = new Set(["inline", "internal_parallel", "external_parallel", "hybrid"]); - if (!validStrategies.has(parsed.strategy)) return INLINE_FALLBACK; - if (!Array.isArray(parsed.subtasks)) return INLINE_FALLBACK; - const validComplexities = new Set(["trivial", "simple", "moderate", "complex"]); - const subtasks = parsed.subtasks - .filter((s: unknown) => s && typeof s === "object" && !Array.isArray(s)) - .map((s: Record<string, unknown>) => ({ - title: String(s.title ?? ""), - instructions: String(s.instructions ?? ""), - files: Array.isArray(s.files) ? s.files.filter((f): f is string => typeof f === "string") : [], - complexity: validComplexities.has(String(s.complexity ?? "")) ? String(s.complexity) : "moderate", - ...(typeof s.estimatedTokens === "number" ? { estimatedTokens: s.estimatedTokens } : {}) - })) as FanOutDecision["subtasks"]; - const clusters = Array.isArray(parsed.clusters) - ? parsed.clusters - .filter((c: unknown) => c && typeof c === "object" && !Array.isArray(c)) - .map((c: Record<string, unknown>) => ({ - subtaskIndices: Array.isArray(c.subtaskIndices) - ? c.subtaskIndices.filter((i): i is number => typeof i === "number") - : [], - reason: String(c.reason ?? "") - })) - : undefined; - return { - strategy: parsed.strategy as FanOutDecision["strategy"], - subtasks, - reasoning: String(parsed.reasoning ?? ""), - ...(clusters && clusters.length > 0 ? { clusters } : {}) - }; - } catch { - return INLINE_FALLBACK; - } -} diff --git a/apps/desktop/src/main/services/orchestrator/metricsAndUsage.ts b/apps/desktop/src/main/services/orchestrator/metricsAndUsage.ts deleted file mode 100644 index cbad8134e..000000000 --- a/apps/desktop/src/main/services/orchestrator/metricsAndUsage.ts +++ /dev/null @@ -1,458 +0,0 @@ -/** - * metricsAndUsage.ts - * - * Metrics and usage: getMissionMetrics, setMissionMetricsConfig, - * getAggregatedUsage, propagateAttemptTokenUsage, token usage tracking, - * createContextCheckpoint, recordLaneDecision, recordMissionMetricSample. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import { randomUUID } from "node:crypto"; -import type { - OrchestratorContext, -} from "./orchestratorContext"; -import { - nowIso, - clampLimit, - parseJsonArray, - parseJsonRecord, - DEFAULT_METRIC_TOGGLES, - KNOWN_METRIC_TOGGLES, -} from "./orchestratorContext"; -import { - getMissionIdentity, - emitThreadEvent, -} from "./chatMessageService"; -import type { - MissionMetricsConfig, - MissionMetricToggle, - MissionMetricSample, - SetMissionMetricsConfigArgs, - GetMissionMetricsArgs, - AggregatedUsageStats, - GetAggregatedUsageArgs, - UsageModelBreakdown, - UsageRecentSession, - UsageActiveSession, - UsageMissionBreakdown, - OrchestratorContextCheckpoint, -} from "../../../shared/types"; - -// ── Token Cost Estimation ──────────────────────────────────────── - -const USAGE_TOKEN_COST: Record<string, { input: number; output: number }> = { - "claude-opus": { input: 5 / 1_000_000, output: 25 / 1_000_000 }, - "claude-sonnet": { input: 3 / 1_000_000, output: 15 / 1_000_000 }, - "claude-haiku": { input: 0.8 / 1_000_000, output: 4 / 1_000_000 }, - "codex": { input: 2 / 1_000_000, output: 8 / 1_000_000 }, - "codex-mini": { input: 0.3 / 1_000_000, output: 1.2 / 1_000_000 }, - "default": { input: 3 / 1_000_000, output: 15 / 1_000_000 } -}; - -export function estimateTokenCost(model: string, inputTokens: number, outputTokens: number): number { - const lower = (model ?? "").toLowerCase(); - let rate = USAGE_TOKEN_COST["default"]; - if (lower.includes("opus")) rate = USAGE_TOKEN_COST["claude-opus"]; - else if (lower.includes("sonnet")) rate = USAGE_TOKEN_COST["claude-sonnet"]; - else if (lower.includes("haiku")) rate = USAGE_TOKEN_COST["claude-haiku"]; - else if (lower.includes("codex") && lower.includes("mini")) rate = USAGE_TOKEN_COST["codex-mini"]; - else if (lower.includes("codex") || lower.includes("gpt") || lower.includes("o3") || lower.includes("o4")) rate = USAGE_TOKEN_COST["codex"]; - return inputTokens * rate.input + outputTokens * rate.output; -} - -// ── Mission Metrics Config ─────────────────────────────────────── - -export function setMissionMetricsConfig( - ctx: OrchestratorContext, - configArgs: SetMissionMetricsConfigArgs -): MissionMetricsConfig { - const missionIdentity = getMissionIdentity(ctx, configArgs.missionId); - if (!missionIdentity) throw new Error(`Mission not found: ${configArgs.missionId}`); - const deduped: MissionMetricToggle[] = []; - const seen = new Set<string>(); - for (const toggle of configArgs.toggles ?? []) { - const normalized = String(toggle ?? "").trim(); - if (!normalized.length || seen.has(normalized)) continue; - if (!KNOWN_METRIC_TOGGLES.has(normalized as MissionMetricToggle)) continue; - seen.add(normalized); - deduped.push(normalized as MissionMetricToggle); - } - const toggles = deduped.length > 0 ? deduped : [...DEFAULT_METRIC_TOGGLES]; - const config: MissionMetricsConfig = { - missionId: configArgs.missionId, - toggles, - updatedAt: nowIso() - }; - ctx.db.run( - ` - insert into mission_metrics_config( - mission_id, - project_id, - toggles_json, - updated_at - ) values (?, ?, ?, ?) - on conflict(mission_id) do update set - toggles_json = excluded.toggles_json, - updated_at = excluded.updated_at - `, - [config.missionId, missionIdentity.projectId, JSON.stringify(config.toggles), config.updatedAt] - ); - emitThreadEvent(ctx, { - type: "metrics_updated", - missionId: config.missionId, - runId: null, - reason: "metrics_config", - metadata: { - toggles: config.toggles - } - }); - return config; -} - -// ── Mission Metrics ────────────────────────────────────────────── - -export function getMissionMetrics( - ctx: OrchestratorContext, - metricArgs: GetMissionMetricsArgs -): { - config: MissionMetricsConfig | null; - samples: MissionMetricSample[]; -} { - const configRow = ctx.db.get<{ toggles_json: string | null; updated_at: string | null }>( - ` - select toggles_json, updated_at - from mission_metrics_config - where mission_id = ? - limit 1 - `, - [metricArgs.missionId] - ); - const config: MissionMetricsConfig | null = configRow - ? { - missionId: metricArgs.missionId, - toggles: parseJsonArray(configRow.toggles_json) - .map((entry) => String(entry ?? "")) - .filter((entry): entry is MissionMetricToggle => KNOWN_METRIC_TOGGLES.has(entry as MissionMetricToggle)), - updatedAt: configRow.updated_at ?? nowIso() - } - : null; - const limit = clampLimit(metricArgs.limit, 200, 1_000); - const sampleRows = ctx.db.all<{ - id: string; - mission_id: string; - run_id: string | null; - attempt_id: string | null; - metric: string; - value: number; - unit: string | null; - metadata_json: string | null; - created_at: string; - }>( - ` - select - id, - mission_id, - run_id, - attempt_id, - metric, - value, - unit, - metadata_json, - created_at - from orchestrator_metrics_samples - where mission_id = ? - and (? is null or run_id = ?) - order by created_at desc - limit ? - `, - [metricArgs.missionId, metricArgs.runId ?? null, metricArgs.runId ?? null, limit] - ); - const samples: MissionMetricSample[] = sampleRows.map((row) => ({ - id: row.id, - missionId: row.mission_id, - runId: row.run_id ?? null, - attemptId: row.attempt_id ?? null, - metric: row.metric, - value: Number(row.value ?? 0), - unit: row.unit ?? null, - metadata: parseJsonRecord(row.metadata_json), - createdAt: row.created_at - })); - return { - config, - samples - }; -} - -// ── Aggregated Usage ───────────────────────────────────────────── - -export function getAggregatedUsage( - ctx: OrchestratorContext, - usageArgs: GetAggregatedUsageArgs -): AggregatedUsageStats { - const since = usageArgs.since ?? null; - const usageLimit = usageArgs.limit ?? 100; - const missionFilter = usageArgs.missionId ?? null; - // When filtering by mission, scope ai_usage_log queries to sessions linked to that mission - const missionSessionClause = missionFilter - ? ` and session_id in ( - select oa.executor_session_id from orchestrator_attempts oa - join orchestrator_runs orr on orr.id = oa.run_id - where orr.mission_id = ? and oa.executor_session_id is not null - )` - : ""; - const modelRows = ctx.db.all(` - select provider, model, count(*) as sessions, - coalesce(sum(input_tokens), 0) as input_tokens, - coalesce(sum(output_tokens), 0) as output_tokens, - coalesce(sum(duration_ms), 0) as duration_ms - from ai_usage_log - where (? is null or timestamp >= ?)${missionSessionClause} - group by provider, model order by sessions desc - `, missionFilter ? [since, since, missionFilter] : [since, since]) as Array<{ - provider: string; model: string; sessions: number; - input_tokens: number; output_tokens: number; duration_ms: number; - }>; - const byModel: UsageModelBreakdown[] = modelRows.map((r) => { - const inp = Number(r.input_tokens) || 0; - const out = Number(r.output_tokens) || 0; - return { - provider: r.provider ?? "unknown", model: r.model ?? "unknown", - sessions: Number(r.sessions) || 0, inputTokens: inp, outputTokens: out, - durationMs: Number(r.duration_ms) || 0, - costEstimateUsd: estimateTokenCost(r.model ?? "", inp, out) - }; - }); - const recentRows = ctx.db.all(` - select id, feature, provider, model, - coalesce(input_tokens, 0) as input_tokens, coalesce(output_tokens, 0) as output_tokens, - coalesce(duration_ms, 0) as duration_ms, success, timestamp - from ai_usage_log where (? is null or timestamp >= ?)${missionSessionClause} - order by timestamp desc limit ? - `, missionFilter ? [since, since, missionFilter, usageLimit] : [since, since, usageLimit]) as Array<{ - id: string; feature: string; provider: string; model: string; - input_tokens: number; output_tokens: number; duration_ms: number; - success: number; timestamp: string; - }>; - const recentSessions: UsageRecentSession[] = recentRows.map((r) => ({ - id: r.id, feature: r.feature ?? "", provider: r.provider ?? "unknown", - model: r.model ?? "unknown", inputTokens: Number(r.input_tokens) || 0, - outputTokens: Number(r.output_tokens) || 0, durationMs: Number(r.duration_ms) || 0, - success: r.success === 1 || (r.success as unknown) === true, timestamp: r.timestamp - })); - const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString(); - const activeRows = ctx.db.all(` - select id, feature, provider, model, timestamp from ai_usage_log - where timestamp >= ? and (success is null or success = 0)${missionSessionClause} - order by timestamp desc limit 20 - `, missionFilter ? [fiveMinAgo, missionFilter] : [fiveMinAgo]) as Array<{ - id: string; feature: string; provider: string; model: string; timestamp: string; - }>; - const activeSessions: UsageActiveSession[] = activeRows.map((r) => ({ - id: r.id, feature: r.feature ?? "", provider: r.provider ?? "unknown", - model: r.model ?? "unknown", startedAt: r.timestamp, - elapsedMs: Date.now() - new Date(r.timestamp).getTime() - })); - const missionRows = ctx.db.all(` - select oms.mission_id, - coalesce(sum(case when oms.metric = 'tokens' then oms.value else 0 end), 0) as total_tokens, - coalesce(sum(case when oms.metric = 'cost' then oms.value else 0 end), 0) as cost_estimate_usd, - m.title as mission_title - from orchestrator_metrics_samples oms - left join missions m on m.id = oms.mission_id - where (? is null or oms.mission_id = ?) and (? is null or oms.created_at >= ?) - group by oms.mission_id order by total_tokens desc limit 50 - `, [missionFilter, missionFilter, since, since]) as Array<{ - mission_id: string; total_tokens: number; cost_estimate_usd: number; mission_title: string | null; - }>; - const missionBreakdown: UsageMissionBreakdown[] = missionRows.map((r) => ({ - missionId: r.mission_id, missionTitle: r.mission_title ?? r.mission_id.slice(0, 8), - totalTokens: Number(r.total_tokens) || 0, costEstimateUsd: Number(r.cost_estimate_usd) || 0 - })); - const totalSessions = byModel.reduce((a, x) => a + x.sessions, 0); - const totalInputTokens = byModel.reduce((a, x) => a + x.inputTokens, 0); - const totalOutputTokens = byModel.reduce((a, x) => a + x.outputTokens, 0); - const totalDurationMs = byModel.reduce((a, x) => a + x.durationMs, 0); - const totalCostEstimateUsd = byModel.reduce((a, x) => a + x.costEstimateUsd, 0); - return { - summary: { totalSessions, activeSessions: activeSessions.length, totalInputTokens, totalOutputTokens, totalDurationMs, totalCostEstimateUsd }, - byModel, recentSessions, activeSessions, missionBreakdown - }; -} - -// ── Token Consumption Propagation ────────────────────────────── - -export function propagateAttemptTokenUsage( - ctx: OrchestratorContext, - runId: string, - attemptId: string -): void { - try { - // Get tokens from the attempt's AI sessions - const attempt = ctx.db.get<{ session_id: string | null }>( - "select session_id from orchestrator_attempts where id = ? limit 1", - [attemptId] - ); - if (!attempt?.session_id) return; - - const usage = ctx.db.get<{ total_input: number; total_output: number }>( - `select coalesce(sum(input_tokens), 0) as total_input, coalesce(sum(output_tokens), 0) as total_output - from ai_usage_log where session_id = ?`, - [attempt.session_id] - ); - if (!usage) return; - - const attemptTokens = (usage.total_input ?? 0) + (usage.total_output ?? 0); - if (attemptTokens <= 0) return; - - // Update run metadata - const run = ctx.db.get<{ metadata_json: string | null }>( - "select metadata_json from orchestrator_runs where id = ? limit 1", - [runId] - ); - const currentMeta = run?.metadata_json ? JSON.parse(run.metadata_json) : {}; - const currentTokens = typeof currentMeta.tokensConsumed === "number" ? currentMeta.tokensConsumed : 0; - currentMeta.tokensConsumed = currentTokens + attemptTokens; - - ctx.db.run( - "update orchestrator_runs set metadata_json = ? where id = ?", - [JSON.stringify(currentMeta), runId] - ); - - ctx.logger.debug("ai_orchestrator.tokens_propagated", { - runId, attemptId, - attemptTokens, - totalTokens: currentMeta.tokensConsumed - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.token_propagation_failed", { - runId, attemptId, - error: error instanceof Error ? error.message : String(error) - }); - } -} - -// ── Context Checkpoint Recording ───────────────────────────────── - -export function createContextCheckpoint( - ctx: OrchestratorContext, - args: { - missionId: string; - runId?: string | null; - trigger: OrchestratorContextCheckpoint["trigger"]; - summary: string; - source: OrchestratorContextCheckpoint["source"]; - } -): OrchestratorContextCheckpoint | null { - const missionIdentity = getMissionIdentity(ctx, args.missionId); - if (!missionIdentity) return null; - const checkpoint: OrchestratorContextCheckpoint = { - id: randomUUID(), - missionId: args.missionId, - runId: args.runId ?? null, - trigger: args.trigger, - summary: args.summary, - source: { - digestCount: Math.max(0, Math.floor(Number(args.source.digestCount) || 0)), - chatMessageCount: Math.max(0, Math.floor(Number(args.source.chatMessageCount) || 0)), - compressedMessageCount: Math.max(0, Math.floor(Number(args.source.compressedMessageCount) || 0)) - }, - createdAt: nowIso() - }; - ctx.db.run( - ` - insert into orchestrator_context_checkpoints( - id, - project_id, - mission_id, - run_id, - trigger, - summary, - source_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - checkpoint.id, - missionIdentity.projectId, - checkpoint.missionId, - checkpoint.runId, - checkpoint.trigger, - checkpoint.summary, - JSON.stringify(checkpoint.source), - checkpoint.createdAt - ] - ); - return checkpoint; -} - -// ── Mission Metric Sample Recording ────────────────────────────── - -export function recordMissionMetricSample( - ctx: OrchestratorContext, - args: { - missionId: string; - runId?: string | null; - attemptId?: string | null; - metric: MissionMetricToggle | string; - value: number; - unit?: string | null; - metadata?: Record<string, unknown> | null; - } -): MissionMetricSample | null { - const missionIdentity = getMissionIdentity(ctx, args.missionId); - if (!missionIdentity) return null; - const numericValue = Number(args.value); - if (!Number.isFinite(numericValue)) return null; - const sample: MissionMetricSample = { - id: randomUUID(), - missionId: args.missionId, - runId: args.runId ?? null, - attemptId: args.attemptId ?? null, - metric: args.metric, - value: numericValue, - unit: args.unit ?? null, - metadata: args.metadata ?? null, - createdAt: nowIso() - }; - ctx.db.run( - ` - insert into orchestrator_metrics_samples( - id, - project_id, - mission_id, - run_id, - attempt_id, - metric, - value, - unit, - metadata_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - sample.id, - missionIdentity.projectId, - sample.missionId, - sample.runId, - sample.attemptId, - sample.metric, - sample.value, - sample.unit, - sample.metadata ? JSON.stringify(sample.metadata) : null, - sample.createdAt - ] - ); - emitThreadEvent(ctx, { - type: "metrics_updated", - missionId: sample.missionId, - runId: sample.runId ?? null, - reason: "metric_sample", - metadata: { - metric: sample.metric, - sampleId: sample.id - } - }); - return sample; -} diff --git a/apps/desktop/src/main/services/orchestrator/mission.test.ts b/apps/desktop/src/main/services/orchestrator/mission.test.ts deleted file mode 100644 index 2e3eb426e..000000000 --- a/apps/desktop/src/main/services/orchestrator/mission.test.ts +++ /dev/null @@ -1,1420 +0,0 @@ -import fs from "node:fs"; -import fsPromises from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createOrchestratorService } from "./orchestratorService"; -import { transitionMissionStatus } from "./missionLifecycle"; -import { createMissionService } from "../missions/missionService"; -import { createBuiltInPhaseCards } from "../missions/phaseEngine"; -import { openKvDb } from "../state/kvDb"; -import { createMissionBudgetService } from "./missionBudgetService"; -import type { PackExport, PackType } from "../../../shared/types"; -import { - createInitialMissionStateDocument, - getMissionStateDocumentPath, - getCoordinatorCheckpointPath, - updateMissionStateDocument, - readMissionStateDocument, - writeCoordinatorCheckpoint, - readCoordinatorCheckpoint, - deleteCoordinatorCheckpoint, -} from "./missionStateDoc"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -// ───────────────────────────────────────────────────── -// missionLifecycle — terminal status regression guard -// ───────────────────────────────────────────────────── - -async function createLifecycleFixture(initialStatus: string = "in_progress") { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lifecycle-regression-")); - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-10T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", - projectRoot, null, 0, null, null, null, null, "active", now, null, - ] - ); - - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "Lifecycle Regression Test", - "Test lifecycle regression guard.", initialStatus, "normal", "local", - null, null, null, null, now, now, now, null, - ] - ); - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService: { - create: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - } as any, - projectConfigService: null as any, - aiIntegrationService: null as any, - }); - - const missionService = createMissionService({ db, projectId }); - - const ctx = { - db, - logger: createLogger(), - missionService, - orchestratorService, - projectRoot, - hookCommandRunner: async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 0, - stdout: "", - stderr: "", - spawnError: null, - }), - agentChatService: null, - laneService: null, - projectConfigService: null, - aiIntegrationService: null, - prService: null, - missionBudgetService: null, - onThreadEvent: undefined, - onDagMutation: undefined, - syncLocks: new Set<string>(), - workerStates: new Map(), - activeSteeringDirectives: new Map(), - runRuntimeProfiles: new Map(), - chatMessages: new Map(), - activeChatSessions: new Map(), - chatTurnQueues: new Map(), - activeHealthSweepRuns: new Set<string>(), - sessionRuntimeSignals: new Map(), - attemptRuntimeTrackers: new Map(), - sessionSignalQueues: new Map(), - workerDeliveryThreadQueues: new Map(), - workerDeliveryInterventionCooldowns: new Map(), - runTeamManifests: new Map(), - runRecoveryLoopStates: new Map(), - aiTimeoutBudgetStepLocks: new Set<string>(), - aiTimeoutBudgetRunLocks: new Set<string>(), - aiRetryDecisionLocks: new Set<string>(), - coordinatorSessions: new Map(), - pendingIntegrations: new Map(), - coordinatorThinkingLoops: new Map(), - pendingCoordinatorEvals: new Map(), - coordinatorAgents: new Map(), - coordinatorRecoveryAttempts: new Map(), - teamRuntimeStates: new Map(), - callTypeConfigCache: new Map(), - disposed: { current: false }, - healthSweepTimer: { current: null }, - } as any; - - return { - ctx, - missionId, - missionService, - dispose: () => { - db.close(); - fs.rmSync(projectRoot, { recursive: true, force: true }); - }, - }; -} - -describe("transitionMissionStatus — terminal status regression guard", () => { - it("blocks transition from completed to in_progress", async () => { - const fixture = await createLifecycleFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("blocks transition from failed to running", async () => { - const fixture = await createLifecycleFixture("failed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("failed"); - } finally { - fixture.dispose(); - } - }); - - it("blocks transition from canceled to in_progress", async () => { - const fixture = await createLifecycleFixture("canceled"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("canceled"); - } finally { - fixture.dispose(); - } - }); - - it("allows an explicit restart from canceled to in_progress", async () => { - const fixture = await createLifecycleFixture("canceled"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress", { allowTerminalRestart: true }); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("passes through the regression guard for terminal-to-terminal (completed to completed)", async () => { - const fixture = await createLifecycleFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "completed"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("does not throw for terminal-to-terminal even when missionService rejects it", async () => { - const fixture = await createLifecycleFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "failed"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("allows failed -> canceled (valid in missionService transition table)", async () => { - const fixture = await createLifecycleFixture("failed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "canceled"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("canceled"); - } finally { - fixture.dispose(); - } - }); - - it("allows transition from non-terminal to terminal (in_progress to completed)", async () => { - const fixture = await createLifecycleFixture("in_progress"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "completed", { - outcomeSummary: "All tasks done", - }); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("allows transition from non-terminal to non-terminal (in_progress to intervention_required)", async () => { - const fixture = await createLifecycleFixture("in_progress"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "intervention_required", { - lastError: "Needs human review", - }); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("intervention_required"); - } finally { - fixture.dispose(); - } - }); - - it("blocks transition from completed to intervention_required (non-terminal)", async () => { - const fixture = await createLifecycleFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "intervention_required"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("no-ops when transitioning to the same status with no args", async () => { - const fixture = await createLifecycleFixture("in_progress"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("returns silently for a non-existent mission", async () => { - const fixture = await createLifecycleFixture("in_progress"); - try { - transitionMissionStatus(fixture.ctx, "non-existent-mission-id", "completed"); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────── -// missionBudgetService -// ───────────────────────────────────────────────────── - -function buildExport(packKey: string, packType: PackType, level: "lite" | "standard" | "deep"): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 16, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null - }; -} - -async function createBudgetDbWithProjectAndLane() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mission-budget-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - - const projectId = "proj-1"; - const laneId = "lane-1"; - const now = "2026-02-18T00:00:00.000Z"; - - db.run( - ` - insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?) - `, - [projectId, root, "ADE", "main", now, now] - ); - - db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - laneId, - projectId, - "Lane 1", - null, - "worktree", - "main", - "feature/lane-1", - root, - null, - 0, - null, - null, - null, - null, - "active", - now, - null - ] - ); - - return { - db, - projectId, - laneId, - root, - dispose: () => db.close() - }; -} - -function writeCodexSessionLog(args: { - sessionsRoot: string; - cwd: string; - timestampIso: string; - fileName?: string; - model?: string; - inputTokens: number; - outputTokens: number; -}): void { - const date = new Date(args.timestampIso); - const yyyy = String(date.getUTCFullYear()); - const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(date.getUTCDate()).padStart(2, "0"); - const dayDir = path.join(args.sessionsRoot, yyyy, mm, dd); - fs.mkdirSync(dayDir, { recursive: true }); - const filePath = path.join(dayDir, args.fileName ?? "rollout-test.jsonl"); - const model = args.model ?? "openai/gpt-5"; - const lines = [ - JSON.stringify({ - timestamp: args.timestampIso, - type: "session_meta", - payload: { - id: "session-test-1", - cwd: args.cwd, - }, - }), - JSON.stringify({ - timestamp: args.timestampIso, - type: "turn_context", - payload: { - cwd: args.cwd, - model, - }, - }), - JSON.stringify({ - timestamp: args.timestampIso, - type: "event_msg", - payload: { - type: "token_count", - info: { - total_token_usage: { - input_tokens: args.inputTokens, - output_tokens: args.outputTokens, - cached_input_tokens: 0, - reasoning_output_tokens: 0, - total_tokens: args.inputTokens + args.outputTokens, - }, - last_token_usage: { - input_tokens: args.inputTokens, - output_tokens: args.outputTokens, - cached_input_tokens: 0, - reasoning_output_tokens: 0, - total_tokens: args.inputTokens + args.outputTokens, - }, - }, - }, - }), - ]; - fs.writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8"); -} - -function writeClaudeProjectLog(args: { - projectsRoot: string; - timestampIso: string; - model?: string; - inputTokens: number; - outputTokens: number; -}): void { - const projectDir = path.join(args.projectsRoot, "-tmp-test-project"); - fs.mkdirSync(projectDir, { recursive: true }); - const filePath = path.join(projectDir, "session.jsonl"); - const model = args.model ?? "anthropic/claude-sonnet-4-6"; - const line = JSON.stringify({ - timestamp: args.timestampIso, - message: { - model, - usage: { - input_tokens: args.inputTokens, - output_tokens: args.outputTokens, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - }); - fs.writeFileSync(filePath, `${line}\n`, "utf8"); -} - -describe("missionBudgetService", () => { - it("flags API-key launch estimate when projected spend exceeds remaining envelope", async () => { - const { db, projectId, root, dispose } = await createBudgetDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "api-key", detectedAuth: [] }) - } as any - }); - - const estimate = await budgetService.estimateLaunchBudget({ - launch: { - prompt: "Implement a complex mission pipeline.", - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - }, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 0.05, - weeklyThresholdUsd: 1 - } - } - }, - selectedPhases: createBuiltInPhaseCards().map((phase) => ({ - ...phase, - budget: { - maxTokens: 4_000 - } - })) - }); - - expect(estimate.estimate.mode).toBe("api-key"); - expect(estimate.estimate.estimatedCostUsd).toBeGreaterThan(0); - expect(estimate.hardLimitExceeded).toBe(true); - - dispose(); - }); - - it("uses local Codex session telemetry for subscription preflight and runtime budget snapshots", async () => { - const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const mission = missionService.create({ - prompt: "Use subscription telemetry.", - laneId, - phaseOverride: createBuiltInPhaseCards(), - }); - const codexSessionsRoot = path.join(root, "codex-sessions"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - timestampIso: nowIso, - model: "openai/gpt-5", - inputTokens: 100_000, - outputTokens: 128_750, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - claudeProjectsRoot: path.join(root, "no-claude-logs"), - }, - }, - }, - }), - } as any, - }); - - const estimate = await budgetService.estimateLaunchBudget({ - launch: { - prompt: "Run with local CLI subscription telemetry.", - modelConfig: { - orchestratorModel: { - provider: "codex", - modelId: "openai/gpt-5", - }, - }, - }, - selectedPhases: createBuiltInPhaseCards(), - }); - expect(estimate.estimate.mode).toBe("subscription"); - expect(estimate.estimate.actualSpendUsd).toBeCloseTo(1.23, 6); - expect(estimate.estimate.burnRateUsdPerHour ?? null).toBeNull(); - expect(estimate.estimate.note ?? "").toContain("local CLI telemetry"); - - const snapshot = await budgetService.getMissionBudgetStatus({ - missionId: mission.id, - }); - expect(snapshot.mode).toBe("subscription"); - const codexProvider = snapshot.perProvider.find((provider) => provider.provider === "codex"); - expect(codexProvider?.fiveHour.usedCostUsd).toBeCloseTo(1.23, 6); - expect(snapshot.burnRateUsdPerHour).toBeNull(); - expect(snapshot.dataSources).toContain("~/.codex/sessions/*.jsonl"); - - dispose(); - }); - - it("scopes subscription mission cost to the mission lane worktree", async () => { - const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); - const missionWorktree = path.join(root, ".ade", "worktrees", "mission-lane"); - fs.mkdirSync(missionWorktree, { recursive: true }); - db.run(`update lanes set worktree_path = ? where id = ?`, [missionWorktree, laneId]); - - const missionService = createMissionService({ db, projectId }); - const mission = missionService.create({ - prompt: "Scope mission telemetry to this lane.", - laneId, - phaseOverride: createBuiltInPhaseCards(), - }); - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot: root, - projectConfigService: null as any, - }); - const started = orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "mission-worker", - title: "Mission worker", - stepIndex: 0, - laneId, - executorKind: "manual", - dependencyStepKeys: [], - }, - ], - }); - db.run( - `update orchestrator_runs set metadata_json = ? where id = ?`, - [JSON.stringify({ missionLaneId: laneId }), started.run.id], - ); - - const codexSessionsRoot = path.join(root, "codex-sessions"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - fileName: "rollout-project-root.jsonl", - timestampIso: nowIso, - model: "openai/gpt-5", - inputTokens: 1_000_000, - outputTokens: 1_000_000, - }); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: missionWorktree, - fileName: "rollout-mission-lane.jsonl", - timestampIso: nowIso, - model: "openai/gpt-5", - inputTokens: 100_000, - outputTokens: 128_750, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - claudeProjectsRoot: path.join(root, "no-claude-logs"), - }, - }, - }, - }), - } as any, - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ - missionId: mission.id, - runId: started.run.id, - }); - - expect(snapshot.mode).toBe("subscription"); - expect(snapshot.mission.usedCostUsd).toBeCloseTo(1.23, 6); - - dispose(); - }); - - it("returns per-phase and per-worker budget snapshot with pressure", async () => { - const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const phaseOverride = createBuiltInPhaseCards().map((phase) => ({ - ...phase, - budget: { - maxTokens: 100, - maxTimeMs: 60_000 - } - })); - const mission = missionService.create({ - prompt: "Build and validate mission budget usage telemetry.", - laneId, - phaseOverride - }); - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot: root, - }); - - const started = orchestratorService.startRun({ - missionId: mission.id, - metadata: {}, - steps: [ - { - stepKey: "planning-1", - stepIndex: 0, - title: "Planning task", - laneId, - dependencyStepKeys: [], - retryLimit: 1, - executorKind: "manual", - metadata: { - phaseKey: phaseOverride[0]!.phaseKey, - phaseName: phaseOverride[0]!.name - } - } - ] - }); - - const step = started.steps[0]!; - db.run( - `update orchestrator_steps set status = 'ready', updated_at = ? where id = ? and run_id = ?`, - [new Date().toISOString(), step.id, started.run.id] - ); - - const attempt = await orchestratorService.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "manual" - }); - const sessionId = `session-${randomUUID()}`; - db.run( - `update orchestrator_attempts set executor_session_id = ? where id = ? and run_id = ?`, - [sessionId, attempt.id, started.run.id] - ); - db.run( - ` - insert into ai_usage_log( - id, - timestamp, - feature, - provider, - model, - input_tokens, - output_tokens, - duration_ms, - success, - session_id - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - randomUUID(), - new Date().toISOString(), - "orchestrator", - "claude", - "anthropic/claude-sonnet-4-6", - 250, - 200, - 45_000, - 1, - sessionId - ] - ); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "api-key", detectedAuth: [] }) - } as any - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ - missionId: mission.id, - runId: started.run.id - }); - - expect(snapshot.mode).toBe("api-key"); - expect(snapshot.pressure).toBe("critical"); - expect(snapshot.mission.usedTokens).toBe(450); - expect(snapshot.perPhase.length).toBeGreaterThan(0); - expect(snapshot.perWorker.length).toBeGreaterThan(0); - expect(snapshot.dataSources).toContain("ai_usage_log"); - - dispose(); - }); - - it("enforces subscription hard caps only for providers selected by mission models", async () => { - const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const codexSessionsRoot = path.join(root, "codex-sessions"); - const claudeProjectsRoot = path.join(root, "claude-projects"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - timestampIso: nowIso, - model: "openai/gpt-5.3-codex", - inputTokens: 500, - outputTokens: 500, - }); - writeClaudeProjectLog({ - projectsRoot: claudeProjectsRoot, - timestampIso: nowIso, - model: "anthropic/claude-sonnet-4-6", - inputTokens: 5_000, - outputTokens: 5_000, - }); - - const phaseOverride = createBuiltInPhaseCards().map((phase, index) => ({ - ...phase, - model: { - ...phase.model, - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - position: index, - })); - const mission = missionService.create({ - prompt: "Codex-only mission with provider-scoped hard caps.", - laneId, - phaseOverride, - modelConfig: { - orchestratorModel: { - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 10, - weeklyThresholdUsd: 40, - fiveHourHardStopPercent: 80, - weeklyHardStopPercent: 90, - providerLimits: { - codex: { fiveHourTokenLimit: 10_000, weeklyTokenLimit: 10_000 }, - claude: { fiveHourTokenLimit: 1_000, weeklyTokenLimit: 1_000 }, - }, - }, - }, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - claudeProjectsRoot, - }, - }, - }, - }), - } as any, - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ missionId: mission.id }); - const claudeProvider = snapshot.perProvider.find((provider) => provider.provider === "claude"); - expect((claudeProvider?.fiveHour.usedPct ?? 0)).toBeGreaterThan(80); - expect(snapshot.hardCaps.fiveHourTriggered).toBe(false); - expect(snapshot.hardCaps.weeklyTriggered).toBe(false); - - const telemetry = budgetService.getMissionBudgetTelemetry({ - providers: ["codex"], - providerLimits: { - codex: { fiveHourTokenLimit: 10_000, weeklyTokenLimit: 10_000 }, - }, - }); - expect(telemetry.perProvider).toHaveLength(1); - expect(telemetry.perProvider[0]?.provider).toBe("codex"); - expect(telemetry.perProvider[0]?.fiveHour.usedTokens ?? 0).toBeGreaterThan(0); - - dispose(); - }); - - it("disables hard-stop enforcement when smart budget toggle is off", async () => { - const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const codexSessionsRoot = path.join(root, "codex-sessions"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - timestampIso: nowIso, - model: "openai/gpt-5.3-codex", - inputTokens: 9_000, - outputTokens: 9_000, - }); - const phaseOverride = createBuiltInPhaseCards().map((phase, index) => ({ - ...phase, - model: { - ...phase.model, - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - position: index, - })); - const mission = missionService.create({ - prompt: "Mission with smart budget disabled.", - laneId, - phaseOverride, - modelConfig: { - orchestratorModel: { - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - smartBudget: { - enabled: false, - fiveHourThresholdUsd: 10, - weeklyThresholdUsd: 40, - fiveHourHardStopPercent: 80, - weeklyHardStopPercent: 90, - providerLimits: { - codex: { fiveHourTokenLimit: 1_000, weeklyTokenLimit: 1_000 }, - }, - }, - }, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - }, - }, - }, - }), - } as any, - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ missionId: mission.id }); - expect(snapshot.hardCaps.fiveHourHardStopPercent).toBeNull(); - expect(snapshot.hardCaps.weeklyHardStopPercent).toBeNull(); - expect(snapshot.hardCaps.apiKeyMaxSpendUsd).toBeNull(); - expect(snapshot.hardCaps.fiveHourTriggered).toBe(false); - expect(snapshot.hardCaps.weeklyTriggered).toBe(false); - expect(snapshot.hardCaps.apiKeyTriggered).toBe(false); - - dispose(); - }); -}); - -// ───────────────────────────────────────────────────── -// missionStateDoc -// ───────────────────────────────────────────────────── - -describe("missionStateDoc", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-msd-test-")); - const missionStateDir = path.join(tmpDir, ".ade", "cache", "mission-state"); - fs.mkdirSync(missionStateDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - describe("getMissionStateDocumentPath", () => { - it("returns path under .ade/cache/mission-state", () => { - const result = getMissionStateDocumentPath(tmpDir, "run-abc"); - expect(result).toContain("mission-state"); - expect(result).toContain("mission-state-run-abc.json"); - }); - }); - - describe("getCoordinatorCheckpointPath", () => { - it("returns path under .ade/cache/mission-state", () => { - const result = getCoordinatorCheckpointPath(tmpDir, "run-abc"); - expect(result).toContain("mission-state"); - expect(result).toContain("coordinator-checkpoint-run-abc.json"); - }); - }); - - describe("createInitialMissionStateDocument", () => { - it("creates a document with schemaVersion 1 and empty collections", () => { - const doc = createInitialMissionStateDocument({ - missionId: "mission-1", - runId: "run-1", - goal: "Build feature X", - }); - expect(doc.schemaVersion).toBe(1); - expect(doc.missionId).toBe("mission-1"); - expect(doc.runId).toBe("run-1"); - expect(doc.goal).toBe("Build feature X"); - expect(doc.stepOutcomes).toEqual([]); - expect(doc.decisions).toEqual([]); - expect(doc.activeIssues).toEqual([]); - expect(doc.modifiedFiles).toEqual([]); - expect(doc.pendingInterventions).toEqual([]); - expect(doc.reflections).toEqual([]); - expect(doc.latestRetrospective).toBeNull(); - }); - - it("initializes progress with defaults when not provided", () => { - const doc = createInitialMissionStateDocument({ - missionId: "m1", - runId: "r1", - goal: "Test", - }); - expect(doc.progress.currentPhase).toBe("unknown"); - expect(doc.progress.completedSteps).toBe(0); - expect(doc.progress.totalSteps).toBe(0); - expect(doc.progress.activeWorkers).toEqual([]); - expect(doc.progress.blockedSteps).toEqual([]); - expect(doc.progress.failedSteps).toEqual([]); - }); - - it("accepts partial progress overrides", () => { - const doc = createInitialMissionStateDocument({ - missionId: "m1", - runId: "r1", - goal: "Test", - progress: { currentPhase: "development", totalSteps: 5 }, - }); - expect(doc.progress.currentPhase).toBe("development"); - expect(doc.progress.totalSteps).toBe(5); - expect(doc.progress.completedSteps).toBe(0); - }); - }); - - describe("updateMissionStateDocument", () => { - it("creates a new document and applies the patch", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-update-1", - goal: "Build X", - patch: { - updateProgress: { - currentPhase: "development", - totalSteps: 3, - }, - }, - }); - expect(result.missionId).toBe("m1"); - expect(result.progress.currentPhase).toBe("development"); - expect(result.progress.totalSteps).toBe(3); - }); - - it("adds a step outcome", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-step-outcome", - goal: "Build X", - patch: { - addStepOutcome: { - stepKey: "step-1", - stepName: "Implement auth", - phase: "development", - status: "succeeded", - summary: "Auth module completed", - filesChanged: ["src/auth.ts"], - warnings: [], - completedAt: "2026-03-25T12:00:00.000Z", - }, - }, - }); - expect(result.stepOutcomes).toHaveLength(1); - expect(result.stepOutcomes[0].stepKey).toBe("step-1"); - expect(result.stepOutcomes[0].status).toBe("succeeded"); - expect(result.stepOutcomes[0].filesChanged).toEqual(["src/auth.ts"]); - expect(result.modifiedFiles).toContain("src/auth.ts"); - }); - - it("merges step outcome updates on existing step", async () => { - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-merge-outcome", - goal: "Build X", - patch: { - addStepOutcome: { - stepKey: "step-1", - stepName: "Implement auth", - phase: "development", - status: "in_progress", - summary: "Started", - filesChanged: ["src/auth.ts"], - warnings: [], - completedAt: null, - }, - }, - }); - - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-merge-outcome", - goal: "Build X", - patch: { - addStepOutcome: { - stepKey: "step-1", - stepName: "Implement auth", - phase: "development", - status: "succeeded", - summary: "Completed auth module", - filesChanged: ["src/auth.ts", "src/middleware.ts"], - warnings: [], - completedAt: "2026-03-25T13:00:00.000Z", - }, - }, - }); - expect(result.stepOutcomes).toHaveLength(1); - expect(result.stepOutcomes[0].status).toBe("succeeded"); - expect(result.stepOutcomes[0].summary).toBe("Completed auth module"); - }); - - it("adds decisions", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-decision", - goal: "Build X", - patch: { - addDecision: { - timestamp: "2026-03-25T12:00:00.000Z", - decision: "Use JWT for auth", - rationale: "Standard approach", - context: "Architecture decision", - }, - }, - }); - expect(result.decisions).toHaveLength(1); - expect(result.decisions[0].decision).toBe("Use JWT for auth"); - }); - - it("adds and resolves issues", async () => { - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-issue", - goal: "Build X", - patch: { - addIssue: { - id: "issue-1", - severity: "high", - description: "Auth module failing tests", - affectedSteps: ["step-1"], - status: "open", - }, - }, - }); - - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-issue", - goal: "Build X", - patch: { - resolveIssue: { - id: "issue-1", - resolution: "Fixed the test setup", - }, - }, - }); - expect(result.activeIssues).toHaveLength(1); - expect(result.activeIssues[0].status).toBe("resolved"); - expect(result.decisions.some((d) => d.decision.includes("Resolved issue issue-1"))).toBe(true); - }); - - it("sets and clears finalization state", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-fin", - goal: "Build X", - patch: { - finalization: { - policy: { - kind: "integration", - targetBranch: "main", - draft: false, - prDepth: null, - autoRebase: true, - ciGating: true, - autoLand: false, - autoResolveConflicts: false, - archiveLaneOnLand: true, - mergeMethod: "squash", - conflictResolverModel: null, - reasoningEffort: null, - description: null, - }, - status: "creating_pr", - executionComplete: true, - contractSatisfied: false, - blocked: false, - blockedReason: null, - summary: "Creating PR", - detail: null, - resolverJobId: null, - integrationLaneId: null, - resultLaneId: null, - queueGroupId: null, - queueId: null, - activePrId: null, - waitReason: null, - proposalUrl: null, - prUrls: [], - reviewStatus: null, - mergeReadiness: null, - requirements: [], - warnings: [], - updatedAt: "2026-03-25T12:00:00.000Z", - startedAt: "2026-03-25T12:00:00.000Z", - completedAt: null, - }, - }, - }); - expect(result.finalization).not.toBeNull(); - expect(result.finalization!.status).toBe("creating_pr"); - expect(result.finalization!.policy.kind).toBe("integration"); - - const cleared = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-fin", - goal: "Build X", - patch: { finalization: null }, - }); - expect(cleared.finalization).toBeNull(); - }); - }); - - describe("readMissionStateDocument", () => { - it("returns null when no document exists", async () => { - const doc = await readMissionStateDocument({ - projectRoot: tmpDir, - runId: "nonexistent-run", - }); - expect(doc).toBeNull(); - }); - - it("reads back a previously written document", async () => { - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-read-test", - goal: "Readable goal", - patch: { updateProgress: { currentPhase: "testing" } }, - }); - - const doc = await readMissionStateDocument({ - projectRoot: tmpDir, - runId: "run-read-test", - }); - expect(doc).not.toBeNull(); - expect(doc!.goal).toBe("Readable goal"); - expect(doc!.progress.currentPhase).toBe("testing"); - }); - - it("falls back to the persisted document when a pending write stalls", async () => { - const runId = "run-read-pending-write"; - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId, - goal: "Readable goal", - patch: { updateProgress: { currentPhase: "testing" } }, - }); - - const originalRename = fsPromises.rename.bind(fsPromises); - let releaseRename: () => void = () => {}; - let renameStartedResolve: (() => void) | null = null; - const renameStarted = new Promise<void>((resolve) => { - renameStartedResolve = resolve; - }); - const renameSpy = vi.spyOn(fsPromises, "rename").mockImplementation(async (oldPath, newPath) => { - if (String(oldPath).includes(`mission-state-${runId}.json`)) { - renameStartedResolve?.(); - await new Promise<void>((resolve) => { - releaseRename = resolve; - }); - } - return originalRename(oldPath, newPath); - }); - - const pendingUpdate = updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId, - goal: "Readable goal", - patch: { updateProgress: { currentPhase: "validation" } }, - }); - - try { - await renameStarted; - - const startedAt = Date.now(); - const doc = await readMissionStateDocument({ - projectRoot: tmpDir, - runId, - }); - - expect(Date.now() - startedAt).toBeLessThan(1_500); - expect(doc).not.toBeNull(); - expect(doc!.progress.currentPhase).toBe("testing"); - } finally { - releaseRename(); - await pendingUpdate; - renameSpy.mockRestore(); - } - - const finalDoc = await readMissionStateDocument({ - projectRoot: tmpDir, - runId, - }); - expect(finalDoc?.progress.currentPhase).toBe("validation"); - }); - }); - - describe("writeCoordinatorCheckpoint / readCoordinatorCheckpoint", () => { - it("writes and reads a checkpoint", async () => { - await writeCoordinatorCheckpoint(tmpDir, "run-cp-1", { - version: 1, - runId: "run-cp-1", - missionId: "m1", - conversationSummary: "Worker completed step 1", - lastEventTimestamp: "2026-03-25T12:00:00.000Z", - turnCount: 5, - compactionCount: 1, - savedAt: "2026-03-25T12:01:00.000Z", - }); - - const cp = await readCoordinatorCheckpoint(tmpDir, "run-cp-1"); - expect(cp).not.toBeNull(); - expect(cp!.missionId).toBe("m1"); - expect(cp!.conversationSummary).toBe("Worker completed step 1"); - expect(cp!.turnCount).toBe(5); - expect(cp!.compactionCount).toBe(1); - }); - - it("returns null for non-existent checkpoint", async () => { - const cp = await readCoordinatorCheckpoint(tmpDir, "nonexistent"); - expect(cp).toBeNull(); - }); - - it("rejects invalid checkpoint payload", async () => { - await expect( - writeCoordinatorCheckpoint(tmpDir, "run-bad", { - version: 1, - runId: "", - missionId: "", - conversationSummary: "", - lastEventTimestamp: null, - turnCount: 0, - compactionCount: 0, - savedAt: "", - }), - ).rejects.toThrow("Invalid coordinator checkpoint payload"); - }); - - it("truncates oversized conversation summaries", async () => { - const longSummary = "x".repeat(10_000); - await writeCoordinatorCheckpoint(tmpDir, "run-long", { - version: 1, - runId: "run-long", - missionId: "m1", - conversationSummary: longSummary, - lastEventTimestamp: null, - turnCount: 0, - compactionCount: 0, - savedAt: "2026-03-25T12:00:00.000Z", - }); - const cp = await readCoordinatorCheckpoint(tmpDir, "run-long"); - expect(cp).not.toBeNull(); - expect(cp!.conversationSummary.length).toBeLessThanOrEqual(8_000); - }); - }); - - describe("deleteCoordinatorCheckpoint", () => { - it("deletes an existing checkpoint", async () => { - await writeCoordinatorCheckpoint(tmpDir, "run-del", { - version: 1, - runId: "run-del", - missionId: "m1", - conversationSummary: "Test", - lastEventTimestamp: null, - turnCount: 1, - compactionCount: 0, - savedAt: "2026-03-25T12:00:00.000Z", - }); - - await deleteCoordinatorCheckpoint(tmpDir, "run-del"); - const cp = await readCoordinatorCheckpoint(tmpDir, "run-del"); - expect(cp).toBeNull(); - }); - - it("does not throw when deleting a non-existent checkpoint", async () => { - await expect( - deleteCoordinatorCheckpoint(tmpDir, "nonexistent"), - ).resolves.toBeUndefined(); - }); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts b/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts deleted file mode 100644 index fd1cb0eb8..000000000 --- a/apps/desktop/src/main/services/orchestrator/missionBudgetService.ts +++ /dev/null @@ -1,1581 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { - CreateMissionArgs, - GetMissionBudgetTelemetryArgs, - GetMissionBudgetStatusArgs, - MissionBudgetHardCapStatus, - MissionBudgetForecast, - MissionBudgetPressure, - MissionBudgetProviderSnapshot, - MissionBudgetProviderWindow, - MissionBudgetScopeSnapshot, - MissionBudgetSnapshot, - MissionBudgetTelemetrySnapshot, - MissionPhaseBudgetSnapshot, - MissionPreflightBudgetEstimate, - MissionPreflightPhaseEstimate, - MissionWorkerBudgetSnapshot, - ModelProvider, - PhaseCard, -} from "../../../shared/types"; -import { BUILT_IN_PHASE_KEYS } from "../missions/phaseEngine"; -import { getModelById, resolveModelAlias, resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { estimateTokenCost } from "./metricsAndUsage"; -import type { createMissionService } from "../missions/missionService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import { isRecord, nowIso, parseJsonRecord } from "./orchestratorContext"; - -type RunRow = { - id: string; - status: string; - created_at: string; - started_at: string | null; - completed_at: string | null; - metadata_json: string | null; -}; - -type StepUsageRow = { - step_id: string; - step_key: string; - title: string; - status: string; - metadata_json: string | null; - input_tokens: number; - output_tokens: number; - duration_ms: number; - models_csv: string | null; -}; - -type MissionRow = { - id: string; - metadata_json: string | null; - started_at: string | null; - completed_at: string | null; -}; - -type ClaudeProviderUsage = { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - totalTokens: number; - costUsd: number; - samples: number; -}; - -type ClaudWindowUsage = ClaudeProviderUsage & { - byProvider: Record<string, ClaudeProviderUsage>; - oldestEntryMs: number | null; - oldestByProvider: Record<string, number>; -}; - -type LaunchBudgetEstimate = { - estimate: MissionPreflightBudgetEstimate; - hardLimitExceeded: boolean; - windowUsageCostUsd: number | null; - remainingWindowCostUsd: number | null; - budgetLimitCostUsd: number | null; -}; - -function toNonNegativeInt(value: unknown): number | null { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric < 0) return null; - return Math.max(0, Math.floor(numeric)); -} - -function toNonNegativeNumber(value: unknown): number | null { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric < 0) return null; - return numeric; -} - -function toBudgetScope(args: { - usedTokens: number; - usedTimeMs: number; - usedCostUsd: number; - maxTokens: number | null; - maxTimeMs: number | null; - maxCostUsd: number | null; -}): MissionBudgetScopeSnapshot { - const remainingTokens = args.maxTokens != null ? Math.max(0, args.maxTokens - args.usedTokens) : null; - const remainingTimeMs = args.maxTimeMs != null ? Math.max(0, args.maxTimeMs - args.usedTimeMs) : null; - const remainingCostUsd = args.maxCostUsd != null ? Math.max(0, args.maxCostUsd - args.usedCostUsd) : null; - return { - ...(args.maxTokens != null ? { maxTokens: args.maxTokens } : {}), - ...(args.maxTimeMs != null ? { maxTimeMs: args.maxTimeMs } : {}), - ...(args.maxCostUsd != null ? { maxCostUsd: args.maxCostUsd } : {}), - usedTokens: Math.max(0, Math.floor(args.usedTokens)), - usedTimeMs: Math.max(0, Math.floor(args.usedTimeMs)), - usedCostUsd: Math.max(0, Number(args.usedCostUsd.toFixed(6))), - ...(remainingTokens != null ? { remainingTokens } : {}), - ...(remainingTimeMs != null ? { remainingTimeMs } : {}), - ...(remainingCostUsd != null ? { remainingCostUsd } : {}), - }; -} - -function roundCost(value: number | null): number | null { - if (value == null || !Number.isFinite(value)) return null; - return Number(value.toFixed(6)); -} - -function roundDuration(value: number | null): number | null { - if (value == null || !Number.isFinite(value)) return null; - return Math.max(0, Math.floor(value)); -} - -function clampConfidence(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(1, Number(value.toFixed(2)))); -} - -function buildForecastFromEstimate(args: { - estimatedCostUsd: number | null; - estimatedTimeMs: number | null; - sampleSize: number; - basis: string; -}): MissionBudgetForecast | null { - const hasCost = args.estimatedCostUsd != null && Number.isFinite(args.estimatedCostUsd); - const hasTime = args.estimatedTimeMs != null && Number.isFinite(args.estimatedTimeMs); - if (!hasCost && !hasTime) return null; - const medianCost = hasCost ? Math.max(0, args.estimatedCostUsd ?? 0) : null; - const medianDuration = hasTime ? Math.max(0, args.estimatedTimeMs ?? 0) : null; - const varianceLow = args.sampleSize >= 3 ? 0.8 : 0.65; - const varianceHigh = args.sampleSize >= 3 ? 1.3 : 1.55; - return { - lowCostUsd: medianCost != null ? roundCost(medianCost * varianceLow) : null, - medianCostUsd: medianCost != null ? roundCost(medianCost) : null, - highCostUsd: medianCost != null ? roundCost(medianCost * varianceHigh) : null, - lowDurationMs: medianDuration != null ? roundDuration(medianDuration * varianceLow) : null, - medianDurationMs: medianDuration != null ? roundDuration(medianDuration) : null, - highDurationMs: medianDuration != null ? roundDuration(medianDuration * varianceHigh) : null, - confidence: clampConfidence(0.3 + Math.min(0.45, args.sampleSize / 12)), - sampleSize: Math.max(0, args.sampleSize), - basis: args.basis, - }; -} - -function parseModelString(rawModel: string): { normalized: string; display: string } { - const normalizedRaw = String(rawModel ?? "").trim(); - if (!normalizedRaw.length) { - return { - normalized: "anthropic/claude-sonnet-4-6", - display: "claude-sonnet-4-6", - }; - } - const exact = getModelById(normalizedRaw); - if (exact) { - return { normalized: exact.id, display: exact.displayName }; - } - const alias = resolveModelAlias(normalizedRaw); - if (alias) { - return { normalized: alias.id, display: alias.displayName }; - } - return { normalized: normalizedRaw.toLowerCase(), display: normalizedRaw }; -} - -function resolvePhaseBudgetDefaults(phaseKey: string): { tokens: number; timeMs: number } { - if (phaseKey === BUILT_IN_PHASE_KEYS.development) { - return { tokens: 80_000, timeMs: 45 * 60_000 }; - } - if (phaseKey === BUILT_IN_PHASE_KEYS.testing) { - return { tokens: 24_000, timeMs: 20 * 60_000 }; - } - if (phaseKey === BUILT_IN_PHASE_KEYS.validation) { - return { tokens: 18_000, timeMs: 14 * 60_000 }; - } - if (phaseKey === BUILT_IN_PHASE_KEYS.prAndConflicts) { - return { tokens: 10_000, timeMs: 10 * 60_000 }; - } - return { tokens: 20_000, timeMs: 15 * 60_000 }; -} - -function resolvePhaseFromStep(args: { - metadataJson: string | null; - defaultPhaseKey: string; - availablePhaseKeys: Set<string>; -}): string { - const metadata = parseJsonRecord(args.metadataJson); - const explicit = typeof metadata?.phaseKey === "string" ? metadata.phaseKey.trim() : ""; - if (explicit.length > 0 && args.availablePhaseKeys.has(explicit)) return explicit; - return args.defaultPhaseKey; -} - -function toClaudometerProjectPath(projectId: string): string { - return projectId.replace(/^-/, "/").replace(/-/g, "/"); -} - -function inferProviderFromModel(model: string): ModelProvider { - const lower = (model ?? "").toLowerCase(); - if (lower.includes("codex") || lower.includes("gpt") || lower.includes("o1") || lower.includes("o3") || lower.includes("o4")) { - return "codex"; - } - return "claude"; -} - -function mapFamilyToSubscriptionProvider(family: string): "claude" | "codex" | null { - if (family === "anthropic") return "claude"; - if (family === "openai") return "codex"; - return null; -} - -function resolveModelBudgetPath(modelRef: string): { - subscriptionProvider: "claude" | "codex" | null; - apiMetered: boolean; -} { - const normalized = parseModelString(modelRef).normalized; - const descriptor = resolveModelDescriptor(normalized) ?? resolveModelDescriptor(modelRef); - if (!descriptor) { - return { - subscriptionProvider: null, - apiMetered: true, - }; - } - const subscriptionProvider = descriptor.isCliWrapped - ? mapFamilyToSubscriptionProvider(descriptor.family) - : null; - const apiMetered = - descriptor.authTypes.includes("api-key") - || descriptor.authTypes.includes("openrouter"); - return { - subscriptionProvider, - apiMetered, - }; -} - -function collectModelBudgetPaths(args: { - selectedPhases: PhaseCard[]; - orchestratorModelId: string | null; -}): { - subscriptionProviders: Set<"claude" | "codex">; - hasApiModels: boolean; -} { - const subscriptionProviders = new Set<"claude" | "codex">(); - let hasApiModels = false; - const pushModel = (raw: string | null | undefined): void => { - const modelId = String(raw ?? "").trim(); - if (!modelId.length) return; - const path = resolveModelBudgetPath(modelId); - if (path.subscriptionProvider) { - subscriptionProviders.add(path.subscriptionProvider); - } - if (path.apiMetered) { - hasApiModels = true; - } - }; - for (const phase of args.selectedPhases) { - pushModel(phase.model.modelId); - } - pushModel(args.orchestratorModelId); - return { subscriptionProviders, hasApiModels }; -} - -function isPathWithin(candidate: string, root: string): boolean { - const normalizedCandidate = path.resolve(candidate); - const normalizedRoot = path.resolve(root); - if (normalizedCandidate === normalizedRoot) return true; - return normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`); -} - -function mergeWindowUsage(parts: ClaudWindowUsage[]): ClaudWindowUsage { - const merged: ClaudWindowUsage = { - ...emptyProviderUsage(), - byProvider: {}, - oldestEntryMs: null, - oldestByProvider: {}, - }; - for (const part of parts) { - merged.inputTokens += part.inputTokens; - merged.outputTokens += part.outputTokens; - merged.cacheReadTokens += part.cacheReadTokens; - merged.cacheWriteTokens += part.cacheWriteTokens; - merged.totalTokens += part.totalTokens; - merged.costUsd += part.costUsd; - merged.samples += part.samples; - if (part.oldestEntryMs != null && (merged.oldestEntryMs == null || part.oldestEntryMs < merged.oldestEntryMs)) { - merged.oldestEntryMs = part.oldestEntryMs; - } - for (const [provider, oldest] of Object.entries(part.oldestByProvider)) { - if (!(provider in merged.oldestByProvider) || oldest < merged.oldestByProvider[provider]!) { - merged.oldestByProvider[provider] = oldest; - } - } - for (const [provider, bucket] of Object.entries(part.byProvider)) { - const target = merged.byProvider[provider] ?? (merged.byProvider[provider] = emptyProviderUsage()); - target.inputTokens += bucket.inputTokens; - target.outputTokens += bucket.outputTokens; - target.cacheReadTokens += bucket.cacheReadTokens; - target.cacheWriteTokens += bucket.cacheWriteTokens; - target.totalTokens += bucket.totalTokens; - target.costUsd += bucket.costUsd; - target.samples += bucket.samples; - } - } - merged.costUsd = Number(merged.costUsd.toFixed(6)); - for (const bucket of Object.values(merged.byProvider)) { - bucket.costUsd = Number(bucket.costUsd.toFixed(6)); - } - return merged; -} - -function emptyProviderUsage(): ClaudeProviderUsage { - return { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - costUsd: 0, - samples: 0, - }; -} - -const FIVE_HOUR_MS = 5 * 60 * 60 * 1000; -const WEEKLY_MS = 7 * 24 * 60 * 60 * 1000; - -function readClaudeJsonlWindow(args: { - fromMs: number; - toMs: number; - projectRoot: string | null; - claudeProjectsRoot?: string | null; - logger: Logger; -}): ClaudWindowUsage { - const emptyResult = (): ClaudWindowUsage => ({ - ...emptyProviderUsage(), - byProvider: {}, - oldestEntryMs: null, - oldestByProvider: {}, - }); - - const configuredRoot = typeof args.claudeProjectsRoot === "string" ? args.claudeProjectsRoot.trim() : ""; - const claudeProjectsDir = configuredRoot.length > 0 - ? path.resolve(configuredRoot) - : path.join(os.homedir(), ".claude", "projects"); - if (!fs.existsSync(claudeProjectsDir)) { - return emptyResult(); - } - - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - let costUsd = 0; - let samples = 0; - let oldestEntryMs: number | null = null; - const oldestByProvider: Record<string, number> = {}; - const byProvider: Record<string, ClaudeProviderUsage> = {}; - - const projectRoot = args.projectRoot ? path.resolve(args.projectRoot) : null; - const projectEntries = fs.readdirSync(claudeProjectsDir); - for (const projectEntry of projectEntries) { - const projectPath = path.join(claudeProjectsDir, projectEntry); - let stat: fs.Stats; - try { - stat = fs.statSync(projectPath); - } catch { - continue; - } - if (!stat.isDirectory()) continue; - - const decodedProjectPath = toClaudometerProjectPath(projectEntry); - if (projectRoot) { - const normalizedDecoded = path.resolve(decodedProjectPath); - if (!normalizedDecoded.includes(path.basename(projectRoot)) && !projectRoot.includes(path.basename(normalizedDecoded))) { - continue; - } - } - - let files: string[] = []; - try { - files = fs.readdirSync(projectPath).filter((entry) => entry.endsWith(".jsonl")); - } catch { - continue; - } - - for (const fileName of files) { - const filePath = path.join(projectPath, fileName); - try { - const mtimeMs = fs.statSync(filePath).mtimeMs; - if (mtimeMs < args.fromMs - (24 * 60 * 60 * 1000)) continue; - const text = fs.readFileSync(filePath, "utf8"); - const lines = text.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.length) continue; - let parsed: unknown; - try { - parsed = JSON.parse(trimmed) as unknown; - } catch { - continue; - } - if (!isRecord(parsed)) continue; - const timestampRaw = - (typeof parsed.timestamp === "string" && parsed.timestamp) - || (typeof parsed.createdAt === "string" && parsed.createdAt) - || null; - if (!timestampRaw) continue; - const timestampMs = Date.parse(timestampRaw); - if (!Number.isFinite(timestampMs)) continue; - if (timestampMs < args.fromMs || timestampMs > args.toMs) continue; - - if (projectRoot) { - const cwd = typeof parsed.cwd === "string" ? parsed.cwd : ""; - if (!cwd) continue; - const normalizedCwd = path.resolve(cwd); - if (!normalizedCwd.startsWith(projectRoot)) continue; - } - - const message = isRecord(parsed.message) ? parsed.message : null; - const usage = isRecord(parsed.usage) - ? parsed.usage - : message && isRecord(message.usage) - ? message.usage - : null; - if (!usage) continue; - - const inTokens = Number(usage.input_tokens ?? 0); - const outTokens = Number(usage.output_tokens ?? 0); - const cacheRead = Number(usage.cache_read_input_tokens ?? 0); - const cacheWrite = Number(usage.cache_creation_input_tokens ?? 0); - if (!Number.isFinite(inTokens) || !Number.isFinite(outTokens) || !Number.isFinite(cacheRead) || !Number.isFinite(cacheWrite)) { - continue; - } - const safeIn = Math.max(0, Math.floor(inTokens)); - const safeOut = Math.max(0, Math.floor(outTokens)); - const safeCacheRead = Math.max(0, Math.floor(cacheRead)); - const safeCacheWrite = Math.max(0, Math.floor(cacheWrite)); - - inputTokens += safeIn; - outputTokens += safeOut; - cacheReadTokens += safeCacheRead; - cacheWriteTokens += safeCacheWrite; - - const model = typeof message?.model === "string" - ? message.model - : typeof parsed.model === "string" - ? parsed.model - : "claude-sonnet-4-6"; - const entryCost = estimateTokenCost(model, inTokens, outTokens); - costUsd += entryCost; - samples += 1; - - // Track oldest entry timestamp for time-until-reset calculations - if (oldestEntryMs === null || timestampMs < oldestEntryMs) { - oldestEntryMs = timestampMs; - } - - const provider = inferProviderFromModel(model); - - if (!(provider in oldestByProvider) || timestampMs < oldestByProvider[provider]) { - oldestByProvider[provider] = timestampMs; - } - - const bucket = byProvider[provider] ?? (byProvider[provider] = emptyProviderUsage()); - bucket.inputTokens += safeIn; - bucket.outputTokens += safeOut; - bucket.cacheReadTokens += safeCacheRead; - bucket.cacheWriteTokens += safeCacheWrite; - bucket.totalTokens += safeIn + safeOut + safeCacheRead + safeCacheWrite; - bucket.costUsd += entryCost; - bucket.samples += 1; - } - } catch (error) { - args.logger.debug("mission_budget.read_claude_jsonl_failed", { - filePath, - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - - // Round cost values - for (const bucket of Object.values(byProvider)) { - bucket.costUsd = Number(bucket.costUsd.toFixed(6)); - } - - return { - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, - costUsd: Number(costUsd.toFixed(6)), - samples, - byProvider, - oldestEntryMs, - oldestByProvider, - }; -} - -function readCodexJsonlWindow(args: { - fromMs: number; - toMs: number; - projectRoot: string | null; - codexSessionsRoot?: string | null; - logger: Logger; -}): ClaudWindowUsage { - const emptyResult = (): ClaudWindowUsage => ({ - ...emptyProviderUsage(), - byProvider: {}, - oldestEntryMs: null, - oldestByProvider: {}, - }); - - const configuredRoot = typeof args.codexSessionsRoot === "string" ? args.codexSessionsRoot.trim() : ""; - const sessionsRoot = configuredRoot.length > 0 - ? path.resolve(configuredRoot) - : path.join(os.homedir(), ".codex", "sessions"); - if (!fs.existsSync(sessionsRoot)) return emptyResult(); - - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - let costUsd = 0; - let samples = 0; - let oldestEntryMs: number | null = null; - const oldestByProvider: Record<string, number> = {}; - const byProvider: Record<string, ClaudeProviderUsage> = {}; - - const projectRoot = args.projectRoot ? path.resolve(args.projectRoot) : null; - const files: string[] = []; - const minMtimeMs = args.fromMs - (24 * 60 * 60 * 1000); - const stack = [sessionsRoot]; - while (stack.length > 0) { - const dir = stack.pop()!; - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) { - stack.push(abs); - continue; - } - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; - try { - const stat = fs.statSync(abs); - if (stat.mtimeMs < minMtimeMs) continue; - } catch { - continue; - } - files.push(abs); - } - } - - for (const filePath of files) { - try { - const text = fs.readFileSync(filePath, "utf8"); - const lines = text.split("\n"); - let currentModel = ""; - let sessionCwd = ""; - let prevTotal: { input: number; output: number; cached: number; reasoning: number; total: number } | null = null; - let lastUsageEventKey = ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.length) continue; - let parsed: unknown; - try { - parsed = JSON.parse(trimmed) as unknown; - } catch { - continue; - } - if (!isRecord(parsed)) continue; - const timestampRaw = - (typeof parsed.timestamp === "string" && parsed.timestamp) - || (typeof parsed.createdAt === "string" && parsed.createdAt) - || null; - if (!timestampRaw) continue; - const timestampMs = Date.parse(timestampRaw); - if (!Number.isFinite(timestampMs)) continue; - - const recordType = typeof parsed.type === "string" ? parsed.type : ""; - const payload = isRecord(parsed.payload) ? parsed.payload : null; - if (recordType === "session_meta" && payload && typeof payload.cwd === "string") { - sessionCwd = payload.cwd; - } - if (recordType === "turn_context" && payload && typeof payload.model === "string") { - currentModel = payload.model.trim(); - if (typeof payload.cwd === "string" && payload.cwd.trim().length > 0) { - sessionCwd = payload.cwd.trim(); - } - } - - if (recordType !== "event_msg" || !payload || payload.type !== "token_count") continue; - - const info = isRecord(payload.info) ? payload.info : null; - if (!info) continue; - const totalUsage = isRecord(info.total_token_usage) ? info.total_token_usage : null; - const lastUsage = isRecord(info.last_token_usage) ? info.last_token_usage : null; - - const toUsageNumbers = (source: Record<string, unknown> | null) => { - const input = Number(source?.input_tokens ?? 0); - const output = Number(source?.output_tokens ?? 0); - const cached = Number(source?.cached_input_tokens ?? 0); - const reasoning = Number(source?.reasoning_output_tokens ?? 0); - const total = Number(source?.total_tokens ?? (input + output + cached + reasoning)); - return { - input: Number.isFinite(input) ? Math.max(0, Math.floor(input)) : 0, - output: Number.isFinite(output) ? Math.max(0, Math.floor(output)) : 0, - cached: Number.isFinite(cached) ? Math.max(0, Math.floor(cached)) : 0, - reasoning: Number.isFinite(reasoning) ? Math.max(0, Math.floor(reasoning)) : 0, - total: Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0, - }; - }; - - const totalNow = toUsageNumbers(totalUsage); - const lastNow = toUsageNumbers(lastUsage); - const eventKey = `${timestampMs}:${totalNow.input}:${totalNow.output}:${totalNow.cached}:${totalNow.reasoning}:${totalNow.total}:${lastNow.input}:${lastNow.output}:${lastNow.cached}:${lastNow.reasoning}:${lastNow.total}`; - if (eventKey === lastUsageEventKey) continue; - lastUsageEventKey = eventKey; - - let deltaInput = 0; - let deltaOutput = 0; - let deltaCached = 0; - let deltaReasoning = 0; - - if (totalUsage) { - if (prevTotal) { - deltaInput = Math.max(0, totalNow.input - prevTotal.input); - deltaOutput = Math.max(0, totalNow.output - prevTotal.output); - deltaCached = Math.max(0, totalNow.cached - prevTotal.cached); - deltaReasoning = Math.max(0, totalNow.reasoning - prevTotal.reasoning); - } else { - deltaInput = totalNow.input; - deltaOutput = totalNow.output; - deltaCached = totalNow.cached; - deltaReasoning = totalNow.reasoning; - } - prevTotal = totalNow; - } else { - deltaInput = lastNow.input; - deltaOutput = lastNow.output; - deltaCached = lastNow.cached; - deltaReasoning = lastNow.reasoning; - } - - if (deltaInput + deltaOutput + deltaCached + deltaReasoning <= 0) continue; - if (timestampMs < args.fromMs || timestampMs > args.toMs) continue; - - if (projectRoot) { - const cwd = sessionCwd.trim(); - if (!cwd.length || !isPathWithin(cwd, projectRoot)) continue; - } - - const model = currentModel.length ? currentModel : "openai/gpt-5.4-mini"; - const normalizedModel = parseModelString(model).normalized; - const safeInput = Math.max(0, Math.floor(deltaInput)); - const safeOutput = Math.max(0, Math.floor(deltaOutput)); - const safeCached = Math.max(0, Math.floor(deltaCached)); - - inputTokens += safeInput; - outputTokens += safeOutput; - cacheReadTokens += safeCached; - const entryCost = estimateTokenCost(normalizedModel, safeInput, safeOutput); - costUsd += entryCost; - samples += 1; - - if (oldestEntryMs === null || timestampMs < oldestEntryMs) { - oldestEntryMs = timestampMs; - } - const provider = inferProviderFromModel(normalizedModel); - if (!(provider in oldestByProvider) || timestampMs < oldestByProvider[provider]) { - oldestByProvider[provider] = timestampMs; - } - - const bucket = byProvider[provider] ?? (byProvider[provider] = emptyProviderUsage()); - bucket.inputTokens += safeInput; - bucket.outputTokens += safeOutput; - bucket.cacheReadTokens += safeCached; - bucket.totalTokens += safeInput + safeOutput + safeCached; - bucket.costUsd += entryCost; - bucket.samples += 1; - } - } catch (error) { - args.logger.debug("mission_budget.read_codex_jsonl_failed", { - filePath, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - for (const bucket of Object.values(byProvider)) { - bucket.costUsd = Number(bucket.costUsd.toFixed(6)); - } - return { - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, - costUsd: Number(costUsd.toFixed(6)), - samples, - byProvider, - oldestEntryMs, - oldestByProvider, - }; -} - -function computePressure(scope: MissionBudgetScopeSnapshot): MissionBudgetPressure { - const ratios: number[] = []; - if (typeof scope.maxTokens === "number" && scope.maxTokens > 0) { - ratios.push(scope.usedTokens / scope.maxTokens); - } - if (typeof scope.maxTimeMs === "number" && scope.maxTimeMs > 0) { - ratios.push(scope.usedTimeMs / scope.maxTimeMs); - } - if (typeof scope.maxCostUsd === "number" && scope.maxCostUsd > 0) { - ratios.push(scope.usedCostUsd / scope.maxCostUsd); - } - if (ratios.length === 0) return "normal"; - const maxRatio = Math.max(...ratios); - if (maxRatio >= 0.85) return "critical"; - if (maxRatio >= 0.6) return "warning"; - return "normal"; -} - -function maxPressure(pressures: MissionBudgetPressure[]): MissionBudgetPressure { - if (pressures.includes("critical")) return "critical"; - if (pressures.includes("warning")) return "warning"; - return "normal"; -} - -function pressureRecommendation(pressure: MissionBudgetPressure): string { - if (pressure === "critical") { - return "Critical pressure: run one worker at a time, finish the current milestone, and defer optional work."; - } - if (pressure === "warning") { - return "Warning pressure: reduce parallelism, prefer cheaper models, and defer optional validation."; - } - return "Budget healthy: continue current strategy."; -} - - -export function createMissionBudgetService(args: { - db: AdeDb; - logger: Logger; - projectId: string; - projectRoot: string; - missionService: ReturnType<typeof createMissionService>; - aiIntegrationService?: ReturnType<typeof createAiIntegrationService> | null; - projectConfigService?: ReturnType<typeof createProjectConfigService> | null; -}) { - const { - db, - logger, - projectId, - projectRoot, - missionService, - aiIntegrationService, - projectConfigService, - } = args; - - const resolveBudgetMode = async (): Promise<"subscription" | "api-key"> => { - if (!aiIntegrationService) return "api-key"; - try { - const status = await aiIntegrationService.getStatus(); - const hasSubscriptionCli = (status.detectedAuth ?? []).some( - (entry) => entry.type === "cli-subscription" && entry.authenticated !== false, - ); - if (status.mode === "subscription" && hasSubscriptionCli) return "subscription"; - return "api-key"; - } catch { - return "api-key"; - } - }; - - const resolveTelemetryConfig = (): { - enabled: boolean; - claudeProjectsRoot: string | null; - codexSessionsRoot: string | null; - } => { - const telemetry = projectConfigService?.get().effective.cto?.budgetTelemetry; - const claudeProjectsRoot = - typeof telemetry?.claudeProjectsRoot === "string" && telemetry.claudeProjectsRoot.trim().length > 0 - ? telemetry.claudeProjectsRoot.trim() - : null; - const codexSessionsRoot = - typeof telemetry?.codexSessionsRoot === "string" && telemetry.codexSessionsRoot.trim().length > 0 - ? telemetry.codexSessionsRoot.trim() - : null; - return { - enabled: telemetry?.enabled !== false, - claudeProjectsRoot, - codexSessionsRoot, - }; - }; - - const readClaudeUsageWindow = (windowArgs: { - fromMs: number; - toMs: number; - projectRoot: string | null; - }): ClaudWindowUsage => { - const telemetry = resolveTelemetryConfig(); - if (!telemetry.enabled) return mergeWindowUsage([]); - return readClaudeJsonlWindow({ - ...windowArgs, - claudeProjectsRoot: telemetry.claudeProjectsRoot, - logger, - }); - }; - - const readCodexUsageWindow = (windowArgs: { - fromMs: number; - toMs: number; - projectRoot: string | null; - }): ClaudWindowUsage => { - const telemetry = resolveTelemetryConfig(); - if (!telemetry.enabled) return mergeWindowUsage([]); - return readCodexJsonlWindow({ - ...windowArgs, - codexSessionsRoot: telemetry.codexSessionsRoot, - logger, - }); - }; - - const resolveMissionCliTelemetryRoot = (runMetadata: Record<string, unknown> | null): string | null => { - const teamRuntime = isRecord(runMetadata?.teamRuntime) ? runMetadata.teamRuntime : null; - const missionLaneId = - typeof runMetadata?.missionLaneId === "string" && runMetadata.missionLaneId.trim().length > 0 - ? runMetadata.missionLaneId.trim() - : typeof teamRuntime?.missionLaneId === "string" && teamRuntime.missionLaneId.trim().length > 0 - ? teamRuntime.missionLaneId.trim() - : ""; - if (!missionLaneId.length) return projectRoot; - const lane = db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? and project_id = ? limit 1`, - [missionLaneId, projectId], - ); - const worktreePath = typeof lane?.worktree_path === "string" ? lane.worktree_path.trim() : ""; - return worktreePath.length > 0 ? worktreePath : projectRoot; - }; - - const collectProviderWindowUsage = (args: { - nowMs: number; - }): { - fiveHourUsage: ClaudWindowUsage; - weeklyUsage: ClaudWindowUsage; - dataSources: string[]; - } => { - const fiveHourClaudeUsage = readClaudeUsageWindow({ - fromMs: args.nowMs - FIVE_HOUR_MS, - toMs: args.nowMs, - projectRoot: null, - }); - const fiveHourCodexUsage = readCodexUsageWindow({ - fromMs: args.nowMs - FIVE_HOUR_MS, - toMs: args.nowMs, - projectRoot: null, - }); - const fiveHourUsage = mergeWindowUsage([fiveHourClaudeUsage, fiveHourCodexUsage]); - - const weeklyClaudeUsage = readClaudeUsageWindow({ - fromMs: args.nowMs - WEEKLY_MS, - toMs: args.nowMs, - projectRoot: null, - }); - const weeklyCodexUsage = readCodexUsageWindow({ - fromMs: args.nowMs - WEEKLY_MS, - toMs: args.nowMs, - projectRoot: null, - }); - const weeklyUsage = mergeWindowUsage([weeklyClaudeUsage, weeklyCodexUsage]); - - const dataSources: string[] = []; - if (fiveHourClaudeUsage.samples > 0 || weeklyClaudeUsage.samples > 0) { - dataSources.push("~/.claude/projects/*.jsonl"); - } - if (fiveHourCodexUsage.samples > 0 || weeklyCodexUsage.samples > 0) { - dataSources.push("~/.codex/sessions/*.jsonl"); - } - return { - fiveHourUsage, - weeklyUsage, - dataSources, - }; - }; - - const estimateLaunchBudget = async (args: { - launch: CreateMissionArgs; - selectedPhases: PhaseCard[]; - }): Promise<LaunchBudgetEstimate> => { - const mode = await resolveBudgetMode(); - const orchestratorModelId = String(args.launch.modelConfig?.orchestratorModel?.modelId ?? "").trim() || null; - const selectedModelPaths = collectModelBudgetPaths({ - selectedPhases: args.selectedPhases, - orchestratorModelId, - }); - const includesApiModels = selectedModelPaths.hasApiModels || mode === "api-key"; - const perPhase: MissionPreflightPhaseEstimate[] = []; - - let totalTokens = 0; - let totalTimeMs = 0; - let totalCostUsd = 0; - let hasTokenEstimate = false; - let hasTimeEstimate = false; - let hasCostEstimate = false; - - for (const phase of args.selectedPhases) { - const defaults = resolvePhaseBudgetDefaults(phase.phaseKey); - const configuredTokens = toNonNegativeInt(phase.budget.maxTokens); - const configuredTimeMs = toNonNegativeInt(phase.budget.maxTimeMs); - const estimatedTokens = configuredTokens ?? defaults.tokens; - const estimatedTimeMs = configuredTimeMs ?? defaults.timeMs; - const model = parseModelString(phase.model.modelId).normalized; - const modelBudgetPath = resolveModelBudgetPath(model); - const estimatedCostUsd = includesApiModels && (mode === "api-key" || modelBudgetPath.apiMetered) - ? estimateTokenCost(model, Math.floor(estimatedTokens * 0.6), Math.floor(estimatedTokens * 0.4)) - : null; - - totalTokens += estimatedTokens; - totalTimeMs += estimatedTimeMs; - if (estimatedCostUsd != null) { - totalCostUsd += estimatedCostUsd; - hasCostEstimate = true; - } - hasTokenEstimate = true; - hasTimeEstimate = true; - - perPhase.push({ - phaseKey: phase.phaseKey, - phaseName: phase.name, - estimatedTokens, - estimatedCostUsd: estimatedCostUsd != null ? Number(estimatedCostUsd.toFixed(6)) : null, - estimatedTimeMs, - configuredMaxTokens: configuredTokens, - configuredMaxTimeMs: configuredTimeMs, - }); - } - - const smartBudget = args.launch.modelConfig?.smartBudget; - const budgetLimitCostUsd = smartBudget?.enabled === true - ? toNonNegativeNumber(smartBudget.fiveHourThresholdUsd) ?? null - : null; - - let windowUsageCostUsd: number | null = null; - let remainingWindowCostUsd: number | null = null; - if (mode === "subscription" || budgetLimitCostUsd != null) { - const nowMs = Date.now(); - const fiveHoursAgo = nowMs - FIVE_HOUR_MS; - const usage = mergeWindowUsage([ - readClaudeUsageWindow({ - fromMs: fiveHoursAgo, - toMs: nowMs, - projectRoot: null, - }), - readCodexUsageWindow({ - fromMs: fiveHoursAgo, - toMs: nowMs, - projectRoot: null, - }), - ]); - if (mode === "subscription") { - windowUsageCostUsd = usage.costUsd; - } - if (windowUsageCostUsd == null) { - windowUsageCostUsd = usage.costUsd; - } - if (budgetLimitCostUsd != null) { - remainingWindowCostUsd = Math.max(0, budgetLimitCostUsd - usage.costUsd); - } - } - const actualSpendUsd = roundCost(windowUsageCostUsd ?? null); - const burnRateUsdPerHour = mode === "api-key" && includesApiModels && actualSpendUsd != null - ? roundCost(actualSpendUsd / 5) - : null; - const forecast = includesApiModels && hasCostEstimate - ? buildForecastFromEstimate({ - estimatedCostUsd: Number(totalCostUsd.toFixed(6)), - estimatedTimeMs: hasTimeEstimate ? totalTimeMs : null, - sampleSize: Math.max(1, perPhase.length), - basis: "phase budget heuristics + selected model pricing", - }) - : null; - - const hardLimitExceeded = includesApiModels - && remainingWindowCostUsd != null - && totalCostUsd > remainingWindowCostUsd; - - const estimate: MissionPreflightBudgetEstimate = { - mode, - estimatedTokens: hasTokenEstimate ? totalTokens : null, - estimatedCostUsd: hasCostEstimate ? Number(totalCostUsd.toFixed(6)) : null, - estimatedTimeMs: hasTimeEstimate ? totalTimeMs : null, - actualSpendUsd, - burnRateUsdPerHour, - ...(forecast ? { forecast } : {}), - perPhase, - ...(remainingWindowCostUsd != null - ? { - note: `Estimated remaining 5-hour capacity: ~$${remainingWindowCostUsd.toFixed(2)} (based on local CLI session usage).`, - } - : mode === "subscription" - ? { - note: "Subscription mode uses observed local CLI telemetry; predictive cost forecasts are intentionally disabled.", - } - : {}), - }; - - return { - estimate, - hardLimitExceeded, - windowUsageCostUsd, - remainingWindowCostUsd, - budgetLimitCostUsd, - }; - }; - - const getMissionBudgetStatus = async ( - budgetArgs: GetMissionBudgetStatusArgs, - ): Promise<MissionBudgetSnapshot> => { - const missionId = String(budgetArgs.missionId ?? "").trim(); - if (!missionId.length) throw new Error("missionId is required."); - const mission = missionService.get(missionId); - if (!mission) throw new Error(`Mission not found: ${missionId}`); - - const missionRow = db.get<MissionRow>( - ` - select id, metadata_json, started_at, completed_at - from missions - where id = ? - and project_id = ? - limit 1 - `, - [missionId, projectId], - ); - if (!missionRow) throw new Error(`Mission not found: ${missionId}`); - - const runIdFilter = typeof budgetArgs.runId === "string" && budgetArgs.runId.trim().length > 0 - ? budgetArgs.runId.trim() - : null; - const includeCliTelemetry = budgetArgs.includeCliTelemetry !== false; - const runRow = db.get<RunRow>( - ` - select id, status, created_at, started_at, completed_at, metadata_json - from orchestrator_runs - where mission_id = ? - and project_id = ? - and (? is null or id = ?) - order by created_at desc - limit 1 - `, - [missionId, projectId, runIdFilter, runIdFilter], - ); - const phaseConfiguration = missionService.getPhaseConfiguration(missionId); - const selectedPhases = phaseConfiguration?.selectedPhases ?? []; - const phaseByKey = new Map(selectedPhases.map((phase) => [phase.phaseKey, phase] as const)); - const phaseKeySet = new Set<string>(selectedPhases.map((phase) => phase.phaseKey)); - const fallbackPhaseKey = selectedPhases[0]?.phaseKey ?? BUILT_IN_PHASE_KEYS.development; - - const stepRows: StepUsageRow[] = runRow - ? db.all<StepUsageRow>( - ` - select - s.id as step_id, - s.step_key, - s.title, - s.status, - s.metadata_json, - coalesce(sum(u.input_tokens), 0) as input_tokens, - coalesce(sum(u.output_tokens), 0) as output_tokens, - coalesce(sum(u.duration_ms), 0) as duration_ms, - group_concat(distinct u.model) as models_csv - from orchestrator_steps s - left join orchestrator_attempts a - on a.project_id = s.project_id - and a.step_id = s.id - left join ai_usage_log u - on u.session_id = a.executor_session_id - where s.project_id = ? - and s.run_id = ? - group by s.id, s.step_key, s.title, s.status, s.metadata_json - order by s.step_index asc, s.created_at asc - `, - [projectId, runRow.id], - ) - : []; - - const mode = await resolveBudgetMode(); - const metadata = parseJsonRecord(missionRow.metadata_json); - const runMetadata = parseJsonRecord(runRow?.metadata_json ?? null); - const launchMeta = metadata && isRecord(metadata.launch) ? metadata.launch : null; - const modelConfig = launchMeta && isRecord(launchMeta.modelConfig) ? launchMeta.modelConfig : null; - const smartBudget = modelConfig && isRecord(modelConfig.smartBudget) ? modelConfig.smartBudget : null; - const smartBudgetEnabled = smartBudget?.enabled === true; - const orchestratorModelId = modelConfig && isRecord(modelConfig.orchestratorModel) && typeof (modelConfig.orchestratorModel as Record<string, unknown>).modelId === "string" - ? (modelConfig.orchestratorModel as Record<string, unknown>).modelId as string - : "anthropic/claude-sonnet-4-6"; - const selectedModelPaths = collectModelBudgetPaths({ - selectedPhases, - orchestratorModelId, - }); - const selectedSubscriptionProviders = selectedModelPaths.subscriptionProviders; - const hasSelectedApiModels = selectedModelPaths.hasApiModels || mode === "api-key"; - const configuredMaxCostUsd = smartBudgetEnabled - ? toNonNegativeNumber(smartBudget.fiveHourThresholdUsd) ?? null - : null; - - const configuredMaxTokens = (() => { - const values = selectedPhases - .map((phase) => toNonNegativeInt(phase.budget.maxTokens)) - .filter((value): value is number => value != null); - if (!values.length) return null; - return values.reduce((sum, value) => sum + value, 0); - })(); - const configuredMaxTimeMs = (() => { - const values = selectedPhases - .map((phase) => toNonNegativeInt(phase.budget.maxTimeMs)) - .filter((value): value is number => value != null); - if (!values.length) return null; - return values.reduce((sum, value) => sum + value, 0); - })(); - - const perWorker: MissionWorkerBudgetSnapshot[] = []; - const perPhaseMap = new Map<string, { - phaseName: string; - stepCount: number; - usedTokens: number; - usedTimeMs: number; - usedCostUsd: number; - maxTokens: number | null; - maxTimeMs: number | null; - }>(); - - for (const phase of selectedPhases) { - perPhaseMap.set(phase.phaseKey, { - phaseName: phase.name, - stepCount: 0, - usedTokens: 0, - usedTimeMs: 0, - usedCostUsd: 0, - maxTokens: toNonNegativeInt(phase.budget.maxTokens), - maxTimeMs: toNonNegativeInt(phase.budget.maxTimeMs), - }); - } - - let activeWorkers = 0; - for (const row of stepRows) { - const phaseKey = resolvePhaseFromStep({ - metadataJson: row.metadata_json, - defaultPhaseKey: fallbackPhaseKey, - availablePhaseKeys: phaseKeySet, - }); - const phase = phaseByKey.get(phaseKey); - const phaseName = phase?.name ?? phaseKey; - const inputTokens = Math.max(0, Math.floor(Number(row.input_tokens ?? 0))); - const outputTokens = Math.max(0, Math.floor(Number(row.output_tokens ?? 0))); - const totalTokens = inputTokens + outputTokens; - const usedTimeMs = Math.max(0, Math.floor(Number(row.duration_ms ?? 0))); - const models = (row.models_csv ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - const model = models[0] ?? phase?.model.modelId ?? "anthropic/claude-sonnet-4-6"; - const modelBudgetPath = resolveModelBudgetPath(model); - const meteredCostUsd = mode === "api-key" || modelBudgetPath.apiMetered - ? estimateTokenCost(model, inputTokens, outputTokens) - : 0; - const usedCostUsd = Number(meteredCostUsd.toFixed(6)); - - if (row.status === "running") activeWorkers += 1; - - if (totalTokens > 0 || usedTimeMs > 0 || usedCostUsd > 0 || row.status === "running") { - perWorker.push({ - stepId: row.step_id, - stepKey: row.step_key, - title: row.title, - phaseKey, - phaseName, - ...toBudgetScope({ - usedTokens: totalTokens, - usedTimeMs, - usedCostUsd, - maxTokens: toNonNegativeInt(phase?.budget.maxTokens), - maxTimeMs: toNonNegativeInt(phase?.budget.maxTimeMs), - maxCostUsd: null, - }), - }); - } - - const aggregate = perPhaseMap.get(phaseKey) ?? { - phaseName, - stepCount: 0, - usedTokens: 0, - usedTimeMs: 0, - usedCostUsd: 0, - maxTokens: null, - maxTimeMs: null, - }; - aggregate.stepCount += 1; - aggregate.usedTokens += totalTokens; - aggregate.usedTimeMs += usedTimeMs; - aggregate.usedCostUsd += usedCostUsd; - perPhaseMap.set(phaseKey, aggregate); - } - - const perPhase: MissionPhaseBudgetSnapshot[] = [...perPhaseMap.entries()] - .map(([phaseKey, aggregate]) => ({ - phaseKey, - phaseName: aggregate.phaseName, - stepCount: aggregate.stepCount, - ...toBudgetScope({ - usedTokens: aggregate.usedTokens, - usedTimeMs: aggregate.usedTimeMs, - usedCostUsd: aggregate.usedCostUsd, - maxTokens: aggregate.maxTokens, - maxTimeMs: aggregate.maxTimeMs, - maxCostUsd: null, - }), - })) - .sort((a, b) => a.phaseName.localeCompare(b.phaseName)); - - let missionUsedTokens = perPhase.reduce((sum, phase) => sum + phase.usedTokens, 0); - let missionUsedTimeMs = perPhase.reduce((sum, phase) => sum + phase.usedTimeMs, 0); - let missionUsedCostUsd = perPhase.reduce((sum, phase) => sum + phase.usedCostUsd, 0); - - const dataSources = ["ai_usage_log", "orchestrator_attempts"]; - - const runStart = Date.parse(runRow?.started_at ?? missionRow.started_at ?? missionRow.completed_at ?? nowIso()); - const runEnd = Date.parse(runRow?.completed_at ?? missionRow.completed_at ?? nowIso()); - const missionCliTelemetryRoot = resolveMissionCliTelemetryRoot(runMetadata); - const scopedCliUsage = includeCliTelemetry && mode === "subscription" && Number.isFinite(runStart) && Number.isFinite(runEnd) - ? mergeWindowUsage([ - readClaudeUsageWindow({ - fromMs: runStart, - toMs: runEnd, - projectRoot: missionCliTelemetryRoot, - }), - readCodexUsageWindow({ - fromMs: runStart, - toMs: runEnd, - projectRoot: missionCliTelemetryRoot, - }), - ]) - : null; - - if (scopedCliUsage && scopedCliUsage.samples > 0) { - if (missionUsedTokens === 0) { - missionUsedTokens = scopedCliUsage.totalTokens; - } - if (missionUsedCostUsd === 0 && !hasSelectedApiModels) { - missionUsedCostUsd = scopedCliUsage.costUsd; - } - if (!dataSources.includes("~/.claude/projects/*.jsonl") && Object.keys(scopedCliUsage.byProvider).includes("claude")) { - dataSources.push("~/.claude/projects/*.jsonl"); - } - if (!dataSources.includes("~/.codex/sessions/*.jsonl") && Object.keys(scopedCliUsage.byProvider).includes("codex")) { - dataSources.push("~/.codex/sessions/*.jsonl"); - } - } - - if (missionUsedTimeMs === 0 && runRow?.started_at) { - const startedMs = Date.parse(runRow.started_at); - const completedMs = runRow.completed_at ? Date.parse(runRow.completed_at) : Date.now(); - if (Number.isFinite(startedMs) && Number.isFinite(completedMs) && completedMs >= startedMs) { - missionUsedTimeMs = Math.max(0, Math.floor(completedMs - startedMs)); - } - } - - const missionScope = toBudgetScope({ - usedTokens: missionUsedTokens, - usedTimeMs: missionUsedTimeMs, - usedCostUsd: missionUsedCostUsd, - maxTokens: configuredMaxTokens, - maxTimeMs: configuredMaxTimeMs, - maxCostUsd: configuredMaxCostUsd, - }); - - // ── Per-provider window usage ──────────────────────────────── - const nowMs = Date.now(); - const providerWindowUsage = includeCliTelemetry - ? collectProviderWindowUsage({ nowMs }) - : { - fiveHourUsage: mergeWindowUsage([]), - weeklyUsage: mergeWindowUsage([]), - dataSources: [], - }; - const fiveHourUsage = providerWindowUsage.fiveHourUsage; - const weeklyUsage = providerWindowUsage.weeklyUsage; - for (const source of providerWindowUsage.dataSources) { - if (!dataSources.includes(source)) dataSources.push(source); - } - - const providerLimits = smartBudget && isRecord(smartBudget.providerLimits) - ? smartBudget.providerLimits as Record<string, unknown> - : null; - - // Collect providers used in this mission (from step rows + JSONL windows). - // Hard-cap enforcement is scoped to selected providers only. - const missionProviders = new Set<string>(); - for (const row of stepRows) { - const models = (row.models_csv ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - for (const m of models) { - missionProviders.add(inferProviderFromModel(m)); - } - } - for (const p of Object.keys(fiveHourUsage.byProvider)) missionProviders.add(p); - for (const p of Object.keys(weeklyUsage.byProvider)) missionProviders.add(p); - for (const provider of selectedSubscriptionProviders) missionProviders.add(provider); - // Ensure at least the orchestrator model's provider is present for visibility - missionProviders.add(inferProviderFromModel(orchestratorModelId)); - - const fiveHourHardStopPercent = smartBudgetEnabled && typeof smartBudget?.fiveHourHardStopPercent === "number" - ? smartBudget.fiveHourHardStopPercent - : null; - const weeklyHardStopPercent = smartBudgetEnabled && typeof smartBudget?.weeklyHardStopPercent === "number" - ? smartBudget.weeklyHardStopPercent - : null; - const apiKeyMaxSpendUsd = smartBudgetEnabled && typeof smartBudget?.apiKeyMaxSpendUsd === "number" - ? smartBudget.apiKeyMaxSpendUsd - : null; - - let fiveHourTriggered = false; - let weeklyTriggered = false; - let apiKeyTriggered = false; - - const perProvider: MissionBudgetProviderSnapshot[] = [...missionProviders].sort().map((provider) => { - const fiveHrBucket = fiveHourUsage.byProvider[provider] ?? emptyProviderUsage(); - const weeklyBucket = weeklyUsage.byProvider[provider] ?? emptyProviderUsage(); - - const limits = providerLimits && isRecord(providerLimits[provider]) - ? providerLimits[provider] as Record<string, unknown> - : null; - const fiveHourTokenLimit = limits && typeof limits.fiveHourTokenLimit === "number" - ? limits.fiveHourTokenLimit - : null; - const weeklyTokenLimit = limits && typeof limits.weeklyTokenLimit === "number" - ? limits.weeklyTokenLimit - : null; - - const fiveHrPct = fiveHourTokenLimit != null && fiveHourTokenLimit > 0 - ? Number(((fiveHrBucket.totalTokens / fiveHourTokenLimit) * 100).toFixed(1)) - : null; - const weeklyPct = weeklyTokenLimit != null && weeklyTokenLimit > 0 - ? Number(((weeklyBucket.totalTokens / weeklyTokenLimit) * 100).toFixed(1)) - : null; - - const shouldEnforceSubscriptionHardStops = selectedSubscriptionProviders.has(provider as "claude" | "codex"); - if (shouldEnforceSubscriptionHardStops && fiveHourHardStopPercent != null && fiveHrPct != null && fiveHrPct >= fiveHourHardStopPercent) { - fiveHourTriggered = true; - } - if (shouldEnforceSubscriptionHardStops && weeklyHardStopPercent != null && weeklyPct != null && weeklyPct >= weeklyHardStopPercent) { - weeklyTriggered = true; - } - - // Time until the oldest entry in this provider's window "falls off" the sliding window. - // The oldest entry leaves the window at (oldestEntryTimestamp + windowDuration), - // so timeUntilReset = (oldestEntryTimestamp + windowDuration) - now. - const fiveHrOldest = fiveHourUsage.oldestByProvider[provider] ?? null; - const timeUntilFiveHrReset = fiveHrBucket.samples > 0 && fiveHrOldest != null - ? Math.max(0, (fiveHrOldest + FIVE_HOUR_MS) - nowMs) - : null; - - const weeklyOldest = weeklyUsage.oldestByProvider[provider] ?? null; - const timeUntilWeeklyReset = weeklyBucket.samples > 0 && weeklyOldest != null - ? Math.max(0, (weeklyOldest + WEEKLY_MS) - nowMs) - : null; - - const fiveHour: MissionBudgetProviderWindow = { - usedTokens: fiveHrBucket.totalTokens, - limitTokens: fiveHourTokenLimit, - usedPct: fiveHrPct, - usedCostUsd: fiveHrBucket.costUsd, - timeUntilResetMs: timeUntilFiveHrReset, - }; - const weekly: MissionBudgetProviderWindow = { - usedTokens: weeklyBucket.totalTokens, - limitTokens: weeklyTokenLimit, - usedPct: weeklyPct, - usedCostUsd: weeklyBucket.costUsd, - timeUntilResetMs: timeUntilWeeklyReset, - }; - - return { provider, fiveHour, weekly }; - }); - - // API key hard cap check - if (apiKeyMaxSpendUsd != null && hasSelectedApiModels && missionUsedCostUsd >= apiKeyMaxSpendUsd) { - apiKeyTriggered = true; - } - - const hardCaps: MissionBudgetHardCapStatus = { - fiveHourHardStopPercent: typeof fiveHourHardStopPercent === "number" ? fiveHourHardStopPercent : null, - weeklyHardStopPercent: typeof weeklyHardStopPercent === "number" ? weeklyHardStopPercent : null, - apiKeyMaxSpendUsd: typeof apiKeyMaxSpendUsd === "number" ? apiKeyMaxSpendUsd : null, - apiKeySpentUsd: hasSelectedApiModels ? missionUsedCostUsd : 0, - fiveHourTriggered, - weeklyTriggered, - apiKeyTriggered, - }; - - const pressure = maxPressure([ - computePressure(missionScope), - ...perPhase.map((phase) => computePressure(phase)), - ...perWorker.map((worker) => computePressure(worker)), - ]); - const terminalStatuses = new Set(["succeeded", "failed", "blocked", "canceled", "skipped", "superseded"]); - const completedWorkers = stepRows.filter((row) => terminalStatuses.has(row.status)).length; - const remainingStepCount = Math.max(0, stepRows.length - completedWorkers); - const avgTokensPerStep = completedWorkers > 0 ? missionScope.usedTokens / completedWorkers : 0; - const avgTimePerStep = completedWorkers > 0 ? missionScope.usedTimeMs / completedWorkers : 0; - const remainingStepsEstimate = - avgTokensPerStep > 0 && typeof missionScope.remainingTokens === "number" - ? Math.max(0, Math.floor(missionScope.remainingTokens / avgTokensPerStep)) - : null; - const remainingDurationEstimate = - remainingStepsEstimate != null && avgTimePerStep > 0 - ? Math.max(0, Math.floor(remainingStepsEstimate * avgTimePerStep)) - : null; - const elapsedForBurnMs = (() => { - if (missionScope.usedTimeMs > 0) return missionScope.usedTimeMs; - if (!runRow?.started_at) return 0; - const startMs = Date.parse(runRow.started_at); - const endMs = runRow.completed_at ? Date.parse(runRow.completed_at) : Date.now(); - if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0; - return Math.max(0, endMs - startMs); - })(); - const burnRateUsdPerHour = hasSelectedApiModels && elapsedForBurnMs > 0 && missionScope.usedCostUsd > 0 - ? roundCost(missionScope.usedCostUsd / (elapsedForBurnMs / 3_600_000)) - : null; - - let forecast: MissionBudgetForecast | null = null; - if (hasSelectedApiModels) { - if (stepRows.length > 0 && completedWorkers > 0) { - const avgCostPerStep = missionScope.usedCostUsd / completedWorkers; - const avgDurationPerStep = missionScope.usedTimeMs > 0 ? missionScope.usedTimeMs / completedWorkers : null; - const projectedCost = missionScope.usedCostUsd + (avgCostPerStep * remainingStepCount); - const projectedDuration = avgDurationPerStep != null - ? missionScope.usedTimeMs + (avgDurationPerStep * remainingStepCount) - : null; - forecast = { - lowCostUsd: roundCost(missionScope.usedCostUsd + (avgCostPerStep * remainingStepCount * 0.75)), - medianCostUsd: roundCost(projectedCost), - highCostUsd: roundCost(missionScope.usedCostUsd + (avgCostPerStep * remainingStepCount * 1.35)), - lowDurationMs: projectedDuration != null ? roundDuration(missionScope.usedTimeMs + (avgDurationPerStep! * remainingStepCount * 0.75)) : null, - medianDurationMs: projectedDuration != null ? roundDuration(projectedDuration) : null, - highDurationMs: projectedDuration != null ? roundDuration(missionScope.usedTimeMs + (avgDurationPerStep! * remainingStepCount * 1.35)) : null, - confidence: clampConfidence(0.35 + Math.min(0.55, completedWorkers / 10)), - sampleSize: completedWorkers, - basis: "completed step usage in this run", - }; - } else { - forecast = buildForecastFromEstimate({ - estimatedCostUsd: missionScope.maxCostUsd ?? null, - estimatedTimeMs: missionScope.maxTimeMs ?? null, - sampleSize: 0, - basis: "configured mission budget caps", - }); - } - } - - return { - missionId, - runId: runRow?.id ?? null, - computedAt: nowIso(), - mode, - pressure, - mission: missionScope, - perPhase, - perWorker: perWorker.sort((a, b) => b.usedTokens - a.usedTokens), - perProvider, - hardCaps, - activeWorkers, - recommendation: pressureRecommendation(pressure), - burnRateUsdPerHour, - forecast, - estimatedRemainingCapacity: { - steps: remainingStepsEstimate, - durationMs: remainingDurationEstimate, - }, - rateLimits: [], - dataSources, - }; - }; - - const getMissionBudgetTelemetry = ( - telemetryArgs: GetMissionBudgetTelemetryArgs = {}, - ): MissionBudgetTelemetrySnapshot => { - const nowMs = Date.now(); - const providerWindowUsage = collectProviderWindowUsage({ nowMs }); - const requestedProviders = new Set( - (telemetryArgs.providers ?? ["claude", "codex"]) - .map((entry) => String(entry ?? "").trim().toLowerCase()) - .filter((entry) => entry.length > 0), - ); - if (requestedProviders.size === 0) { - requestedProviders.add("claude"); - requestedProviders.add("codex"); - } - const providerLimits = telemetryArgs.providerLimits - ? telemetryArgs.providerLimits as Record<string, unknown> - : null; - const perProvider: MissionBudgetProviderSnapshot[] = [...requestedProviders] - .sort((a, b) => a.localeCompare(b)) - .map((provider) => { - const fiveHrBucket = providerWindowUsage.fiveHourUsage.byProvider[provider] ?? emptyProviderUsage(); - const weeklyBucket = providerWindowUsage.weeklyUsage.byProvider[provider] ?? emptyProviderUsage(); - const limits = providerLimits && isRecord(providerLimits[provider]) - ? providerLimits[provider] as Record<string, unknown> - : null; - const fiveHourTokenLimit = limits && typeof limits.fiveHourTokenLimit === "number" - ? limits.fiveHourTokenLimit - : null; - const weeklyTokenLimit = limits && typeof limits.weeklyTokenLimit === "number" - ? limits.weeklyTokenLimit - : null; - const fiveHrPct = fiveHourTokenLimit != null && fiveHourTokenLimit > 0 - ? Number(((fiveHrBucket.totalTokens / fiveHourTokenLimit) * 100).toFixed(1)) - : null; - const weeklyPct = weeklyTokenLimit != null && weeklyTokenLimit > 0 - ? Number(((weeklyBucket.totalTokens / weeklyTokenLimit) * 100).toFixed(1)) - : null; - const fiveHrOldest = providerWindowUsage.fiveHourUsage.oldestByProvider[provider] ?? null; - const weeklyOldest = providerWindowUsage.weeklyUsage.oldestByProvider[provider] ?? null; - return { - provider, - fiveHour: { - usedTokens: fiveHrBucket.totalTokens, - limitTokens: fiveHourTokenLimit, - usedPct: fiveHrPct, - usedCostUsd: fiveHrBucket.costUsd, - timeUntilResetMs: fiveHrBucket.samples > 0 && fiveHrOldest != null - ? Math.max(0, (fiveHrOldest + FIVE_HOUR_MS) - nowMs) - : null, - }, - weekly: { - usedTokens: weeklyBucket.totalTokens, - limitTokens: weeklyTokenLimit, - usedPct: weeklyPct, - usedCostUsd: weeklyBucket.costUsd, - timeUntilResetMs: weeklyBucket.samples > 0 && weeklyOldest != null - ? Math.max(0, (weeklyOldest + WEEKLY_MS) - nowMs) - : null, - }, - }; - }); - return { - computedAt: nowIso(), - perProvider, - dataSources: providerWindowUsage.dataSources, - }; - }; - - return { - estimateLaunchBudget, - getMissionBudgetStatus, - getMissionBudgetTelemetry, - }; -} - -export type MissionBudgetService = ReturnType<typeof createMissionBudgetService>; diff --git a/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts b/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts deleted file mode 100644 index ba0b40e27..000000000 --- a/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts +++ /dev/null @@ -1,720 +0,0 @@ -/** - * missionLifecycle.ts - * - * Mission run management: start, approve, cancel, cleanup, steer, sync, - * provision lanes, synthesize team manifest, integration contexts. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - * NOTE: Due to the massive cross-dependencies (50+ closure references), - * this module re-exports function signatures. The actual implementations - * remain in the facade until full extraction is complete, and are injected - * via the deps parameter pattern. - * - * This file establishes the module boundary and type contracts. - */ - -import fs from "node:fs"; -import { createHash } from "node:crypto"; -import path from "node:path"; -import type { - OrchestratorContext, - MissionRuntimeProfile, - OrchestratorHookEvent, -} from "./orchestratorContext"; -import { - nowIso, - isRecord, - mapOrchestratorStepStatus, - parseJsonRecord, - readConfig, - deriveRuntimeProfileFromPolicy, - deriveRuntimeProfileFromPhases, - clipHookLogText, -} from "./orchestratorContext"; -import type { - OrchestratorRunGraph, - MissionStatus, - MissionStepStatus, - OrchestratorWorkerRole, - OrchestratorStepStatus, - OrchestratorWorkerStatus, - TerminalRuntimeState, - MissionExecutionPolicy, - MissionLevelSettings, - PhaseCard, -} from "../../../shared/types"; -import { TERMINAL_MISSION_STATUSES } from "../../../shared/types"; -import { resolveExecutionPolicy, DEFAULT_EXECUTION_POLICY } from "./executionPolicy"; -import { getMissionMetadata, getMissionIdForRun } from "./chatMessageService"; -import { readDocPaths } from "./stepPolicyResolver"; - -// ── Pure Step/Attempt Utility Functions ─────────────────────────── - -export function stepTitleForMessage(step: OrchestratorRunGraph["steps"][number]): string { - const raw = String(step.title ?? "").trim(); - if (raw.length > 0) return raw; - return step.stepKey || step.id.slice(0, 8); -} - -export function isRetryQueuedForStep(step: OrchestratorRunGraph["steps"][number] | undefined): boolean { - if (!step) return false; - return step.status === "pending" || step.status === "ready" || step.status === "running"; -} - -export function extractOutcomeTags(attempt: OrchestratorRunGraph["attempts"][number]): string[] { - const tags: string[] = []; - if (attempt.resultEnvelope) { - const envelope = attempt.resultEnvelope; - if (envelope.warnings?.length) tags.push("has_warnings", `warnings:${envelope.warnings.length}`); - if (envelope.outputs && isRecord(envelope.outputs)) { - const filesModified = Number(envelope.outputs.filesModified ?? envelope.outputs.files_modified); - if (Number.isFinite(filesModified)) tags.push(`files_modified:${filesModified}`); - const testsPassed = Number(envelope.outputs.testsPassed ?? envelope.outputs.tests_passed); - if (Number.isFinite(testsPassed)) tags.push(`tests_passed:${testsPassed}`); - } - } - const durationMs = attempt.completedAt && attempt.startedAt - ? Date.parse(attempt.completedAt) - Date.parse(attempt.startedAt) - : null; - if (durationMs != null && Number.isFinite(durationMs)) { - const category = durationMs < 10_000 ? "fast" : durationMs < 60_000 ? "normal" : "slow"; - tags.push(`duration_category:${category}`); - } - return tags; -} - -export function resolveAttemptOwnerId( - run: OrchestratorRunGraph["run"], - attempt: OrchestratorRunGraph["attempts"][number] -): string { - const attemptMeta = isRecord(attempt.metadata) ? attempt.metadata : {}; - const explicitOwner = typeof attemptMeta.ownerId === "string" ? attemptMeta.ownerId.trim() : ""; - if (explicitOwner.length > 0) return explicitOwner; - const runMeta = isRecord(run.metadata) ? run.metadata : {}; - const autopilot = isRecord(runMeta.autopilot) ? runMeta.autopilot : null; - const runOwner = autopilot && typeof autopilot.ownerId === "string" ? autopilot.ownerId.trim() : ""; - if (runOwner.length > 0) return runOwner; - return "orchestrator-autopilot"; -} - -export function resolveAttemptOwnerIdFromRows( - attemptMetadataJson: string | null, - runMetadataJson: string | null -): string { - const attemptMeta = parseJsonRecord(attemptMetadataJson); - const explicitOwner = typeof attemptMeta?.ownerId === "string" ? attemptMeta.ownerId.trim() : ""; - if (explicitOwner.length > 0) return explicitOwner; - const runMeta = parseJsonRecord(runMetadataJson); - const autopilot = runMeta && isRecord(runMeta.autopilot) ? runMeta.autopilot : null; - const owner = autopilot && typeof autopilot.ownerId === "string" ? autopilot.ownerId.trim() : ""; - if (owner.length > 0) return owner; - return "orchestrator-autopilot"; -} - -// ── Helper Functions ───────────────────────────────────────────── - -export function inferRoleFromStepMetadata(metadata: Record<string, unknown>, kind: string): OrchestratorWorkerRole { - const stepType = typeof metadata.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - const taskType = typeof metadata.taskType === "string" ? metadata.taskType.trim().toLowerCase() : ""; - const combined = `${stepType} ${taskType} ${kind}`.toLowerCase(); - if (combined.includes("test_review") || combined.includes("testreview")) return "test_review"; - if (combined.includes("review") || combined.includes("code_review")) return "code_review"; - if (combined.includes("test") || combined.includes("validation")) return "testing"; - if (combined.includes("plan")) return "implementation"; - if (combined.includes("integration") || combined.includes("merge")) return "integration"; - return "implementation"; -} - -export function parseNumericDependencyIndices(metadata: Record<string, unknown>): number[] { - if (!Array.isArray(metadata.dependencyIndices)) return []; - return metadata.dependencyIndices - .map((value: unknown) => Number(value)) - .filter((value: number) => Number.isFinite(value)) - .map((value: number) => Math.floor(value)); -} - -export function slugify(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 28) || "lane"; -} - -export function isParallelCandidateStepType(stepType: string): boolean { - const normalized = stepType.trim().toLowerCase(); - if (normalized === "analysis" || normalized === "summary" || normalized === "review") return false; - if (normalized === "integration" || normalized === "merge") return false; - return true; -} - -// ── Orchestrator Constants ─────────────────────────────────────── - -export const TERMINAL_PHASE_STEP_STATUSES = new Set<OrchestratorStepStatus>([ - "succeeded", - "failed", - "skipped", - "superseded", - "canceled" -]); - - -/** - * Sync mission steps from an orchestrator run graph. - */ -export function syncMissionStepsFromRun(ctx: OrchestratorContext, graph: OrchestratorRunGraph): void { - const missionId = graph.run.missionId; - const mission = ctx.missionService.get(missionId); - if (!mission) return; - - for (const step of graph.steps) { - const missionStep = mission.steps.find((ms) => { - const msMeta = isRecord(ms.metadata) ? ms.metadata : {}; - return msMeta.orchestratorStepId === step.id || msMeta.stepKey === step.stepKey; - }); - if (missionStep) { - const apply = () => { - try { - ctx.missionService.updateStep({ - missionId, - stepId: missionStep.id, - status: mapOrchestratorStepStatus(step.status) as any - }); - } catch { - // ignore step update failures - } - }; - apply(); - } - } -} - -/** - * User-initiated mission step statuses that should propagate back to the - * orchestrator run graph. Only statuses that represent deliberate user - * actions (cancel / skip) are synced — we never push running/pending back - * because those are orchestrator-driven transitions. - */ -const USER_INITIATED_STEP_STATUSES = new Set<MissionStepStatus>([ - "canceled", - "skipped", -]); - -/** - * Map a mission step status to the corresponding orchestrator step status. - * This is the reverse of `mapOrchestratorStepStatus`. - */ -function mapMissionStepStatusToOrchestrator( - status: MissionStepStatus -): OrchestratorStepStatus { - switch (status) { - case "canceled": - return "canceled"; - case "skipped": - return "skipped"; - default: - return status as OrchestratorStepStatus; - } -} - -/** - * Reverse sync: propagate user-initiated status changes (cancelled, skipped) - * from mission steps back to the orchestrator run graph. - * - * This complements `syncMissionStepsFromRun` which only syncs orchestrator → mission. - * The reverse direction ensures that when a user manually cancels or skips a - * mission step in the UI, the orchestrator is aware and stops scheduling work - * for that step. - */ -export function syncRunStepsFromMission( - ctx: OrchestratorContext, - graph: OrchestratorRunGraph -): { synced: number } { - const missionId = graph.run.missionId; - const mission = ctx.missionService.get(missionId); - if (!mission) return { synced: 0 }; - - let synced = 0; - - for (const missionStep of mission.steps) { - // Only propagate user-initiated terminal statuses - if (!USER_INITIATED_STEP_STATUSES.has(missionStep.status)) continue; - - // Find the matching orchestrator step via metadata linkage - const msMeta = isRecord(missionStep.metadata) ? missionStep.metadata : {}; - const orchestratorStepId = - typeof msMeta.orchestratorStepId === "string" - ? msMeta.orchestratorStepId - : null; - const stepKey = - typeof msMeta.stepKey === "string" ? msMeta.stepKey : null; - - const runStep = graph.steps.find((s) => { - if (orchestratorStepId && s.id === orchestratorStepId) return true; - if (stepKey && s.stepKey === stepKey) return true; - return false; - }); - - if (!runStep) continue; - - // Don't overwrite if the orchestrator step is already in a terminal state - const alreadyTerminal = - runStep.status === "succeeded" || - runStep.status === "failed" || - runStep.status === "skipped" || - runStep.status === "superseded" || - runStep.status === "canceled"; - if (alreadyTerminal) continue; - - const targetStatus = mapMissionStepStatusToOrchestrator(missionStep.status); - try { - if (targetStatus === "skipped") { - ctx.orchestratorService.skipStep({ - runId: graph.run.id, - stepId: runStep.id, - reason: `User ${missionStep.status} mission step "${missionStep.title}" via UI`, - }); - } else { - // For canceled: use skipStep with a cancel reason since orchestratorService - // does not expose a dedicated cancelStep — skipStep is the closest equivalent - // that properly handles downstream dependency refreshes. - ctx.orchestratorService.skipStep({ - runId: graph.run.id, - stepId: runStep.id, - reason: `User canceled mission step "${missionStep.title}" via UI`, - }); - } - synced++; - } catch { - // Ignore failures (step may already be terminal in a race) - } - } - - return { synced }; -} - -/** - * Discover project documentation files and return their contents. - */ -export function discoverProjectDocs(ctx: OrchestratorContext): { - found: boolean; - paths: string[]; - docs: Array<{ path: string; bytes: number; sha256: string }>; -} { - if (!ctx.projectRoot) return { found: false, paths: [], docs: [] }; - const projectRoot = ctx.projectRoot; - const candidatePaths = [ - ...new Set( - [ - "README.md", - "CLAUDE.md", - "AGENTS.md", - ...readDocPaths(projectRoot).map((absPath) => path.relative(projectRoot, absPath).replace(/\\/g, "/")) - ].map((value) => value.replace(/\\/g, "/")) - ) - ]; - const foundPaths: string[] = []; - const docs: Array<{ path: string; bytes: number; sha256: string }> = []; - for (const candidate of candidatePaths) { - const fullPath = path.join(projectRoot, candidate.replace(/^\/+/, "")); - try { - if (fs.existsSync(fullPath)) { - const content = fs.readFileSync(fullPath); - if (content.byteLength > 0) { - foundPaths.push(candidate); - docs.push({ - path: candidate, - bytes: content.byteLength, - sha256: createHash("sha256").update(content).digest("hex") - }); - } - } - } catch { - // ignore read errors - } - } - if (foundPaths.length > 0) { - ctx.logger.debug("ai_orchestrator.project_docs_discovered", { - count: foundPaths.length, - paths: foundPaths - }); - } - return { found: foundPaths.length > 0, paths: foundPaths, docs }; -} - - -// ── Policy & Runtime Profile Resolution ───────────────────────── - -export function resolveActivePolicy( - ctx: OrchestratorContext, - missionId: string -): MissionExecutionPolicy { - const metadata = getMissionMetadata(ctx, missionId); - const config = readConfig(ctx.projectConfigService); - - // 1) Mission metadata explicit policy - if (isRecord(metadata.executionPolicy)) { - return resolveExecutionPolicy({ - missionMetadata: metadata.executionPolicy as Partial<MissionExecutionPolicy> - }); - } - - // 2) Project default execution policy - if (config.defaultExecutionPolicy) { - return resolveExecutionPolicy({ - projectConfig: config.defaultExecutionPolicy - }); - } - - // 3) Built-in default policy - return DEFAULT_EXECUTION_POLICY; -} - -export function resolveActiveRuntimeProfile( - ctx: OrchestratorContext, - missionId: string -): MissionRuntimeProfile { - const config = readConfig(ctx.projectConfigService); - - // Try phase-based derivation first - const { phases, settings } = resolveActivePhaseSettings(ctx, missionId); - if (phases.length > 0) { - return deriveRuntimeProfileFromPhases(phases, settings, config); - } - - // Fall back to old policy-based derivation - const policy = resolveActivePolicy(ctx, missionId); - return deriveRuntimeProfileFromPolicy(policy, config); -} - -// ── Phase-based Settings Resolution ────────────────────────────── - -const DEFAULT_MISSION_LEVEL_SETTINGS: MissionLevelSettings = { - prStrategy: { kind: "manual" } -}; - -/** - * Resolves the active phase cards and mission-level settings for a mission. - * Reads from mission metadata with fallback chain. - */ -export function resolveActivePhaseSettings( - ctx: OrchestratorContext, - missionId: string -): { phases: PhaseCard[]; settings: MissionLevelSettings } { - const metadata = getMissionMetadata(ctx, missionId); - - // 1) Read phases from phaseConfiguration in metadata - let phases: PhaseCard[] = []; - if (isRecord(metadata.phaseConfiguration)) { - const config = metadata.phaseConfiguration as Record<string, unknown>; - if (Array.isArray(config.selectedPhases)) { - phases = config.selectedPhases as PhaseCard[]; - } else if (Array.isArray(config.phases)) { - phases = config.phases as PhaseCard[]; - } - } - - // 2) Read mission-level settings - let settings: MissionLevelSettings; - if (isRecord(metadata.missionLevelSettings)) { - settings = metadata.missionLevelSettings as MissionLevelSettings; - } else { - settings = { ...DEFAULT_MISSION_LEVEL_SETTINGS }; - } - - return { phases, settings }; -} - -// ── Mission Status Transition ─────────────────────────────────── - -export function transitionMissionStatus( - ctx: OrchestratorContext, - missionId: string, - next: MissionStatus, - args?: { outcomeSummary?: string | null; lastError?: string | null; allowTerminalRestart?: boolean } -): void { - const mission = ctx.missionService.get(missionId); - if (!mission) return; - if (mission.status === next && args?.outcomeSummary == null && args?.lastError == null) return; - if ( - TERMINAL_MISSION_STATUSES.has(mission.status) && - !TERMINAL_MISSION_STATUSES.has(next) && - !args?.allowTerminalRestart - ) { - ctx.logger.debug("ai_orchestrator.mission_status_regression_skipped", { - missionId, - from: mission.status, - to: next, - }); - return; - } - - // VAL-STATE-001: Before transitioning to intervention_required, pause all - // active runs for this mission so we never have an active run while the - // mission is in the intervention_required state. - if (next === "intervention_required") { - const runs = ctx.orchestratorService.listRuns({ missionId }); - for (const run of runs) { - if (run.status === "active" || run.status === "bootstrapping") { - try { - ctx.orchestratorService.pauseRun({ - runId: run.id, - reason: "Mission transitioning to intervention_required", - }); - } catch (pauseError) { - ctx.logger.debug("ai_orchestrator.pause_run_before_intervention_failed", { - missionId, - runId: run.id, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }); - } - } - } - } - - try { - ctx.missionService.update({ - missionId, - status: next, - ...(args?.outcomeSummary !== undefined ? { outcomeSummary: args.outcomeSummary } : {}), - ...(args?.lastError !== undefined ? { lastError: args.lastError } : {}) - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.mission_status_transition_skipped", { - missionId, - from: mission.status, - to: next, - reason: error instanceof Error ? error.message : String(error) - }); - } -} - -// ── Max Coordinator Recoveries ────────────────────────────────── - -const DEFAULT_MAX_COORDINATOR_RECOVERIES = 3; - -export function getMaxCoordinatorRecoveries( - ctx: OrchestratorContext, - missionId?: string | null -): number { - // Check mission-specific metadata first - if (missionId) { - try { - const metadata = getMissionMetadata(ctx, missionId); - const missionMax = (metadata as Record<string, unknown>)?.maxCoordinatorRecoveries; - if (typeof missionMax === "number" && Number.isFinite(missionMax) && missionMax >= 0) { - return missionMax; - } - } catch { /* fall through */ } - } - // Then project config - try { - const config = readConfig(ctx.projectConfigService); - const execPolicy = config.defaultExecutionPolicy; - const policyMax = (execPolicy as Record<string, unknown> | null)?.maxCoordinatorRecoveries; - if (typeof policyMax === "number" && Number.isFinite(policyMax) && policyMax >= 0) { - return policyMax; - } - } catch { /* use default */ } - return DEFAULT_MAX_COORDINATOR_RECOVERIES; -} - -// ── Hook Dispatch ─────────────────────────────────────────────── - -export type HookDispatchDeps = { - recordRuntimeEvent: (...args: any[]) => void; -}; - -export function dispatchOrchestratorHookCtx( - ctx: OrchestratorContext, - hookArgs: { - event: OrchestratorHookEvent; - runId: string; - stepId?: string | null; - attemptId?: string | null; - sessionId?: string | null; - reason: string; - triggerSource: string; - eventAt?: string | null; - metadata?: Record<string, unknown> | null; - }, - deps: HookDispatchDeps -): void { - const config = readConfig(ctx.projectConfigService); - const hook = config.hooks[hookArgs.event]; - if (!hook?.command) return; - const missionId = getMissionIdForRun(ctx, hookArgs.runId); - const commandPreview = clipHookLogText(hook.command); - const commandDigest = createHash("sha256").update(hook.command).digest("hex").slice(0, 16); - const occurredAt = hookArgs.eventAt && hookArgs.eventAt.trim().length > 0 ? hookArgs.eventAt : nowIso(); - const runtimeEventBase = { - source: "orchestrator_hook", - hookEvent: hookArgs.event, - missionId, - reason: hookArgs.reason, - triggerSource: hookArgs.triggerSource, - commandDigest, - commandPreview, - timeoutMs: hook.timeoutMs - }; - deps.recordRuntimeEvent({ - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - eventType: "progress", - eventKey: `hook_dispatch:${hookArgs.event}:${hookArgs.attemptId ?? hookArgs.stepId ?? "none"}:${Date.now()}`, - occurredAt, - payload: { - ...runtimeEventBase, - phase: "started", - ...(hookArgs.metadata ?? {}) - } - }); - ctx.logger.info("ai_orchestrator.hook_dispatch_started", { - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - ...runtimeEventBase - }); - - const env: Record<string, string> = {}; - for (const [key, value] of Object.entries(process.env)) { - if (typeof value === "string") env[key] = value; - } - env.ADE_HOOK_EVENT = hookArgs.event; - env.ADE_HOOK_RUN_ID = hookArgs.runId; - env.ADE_HOOK_STEP_ID = hookArgs.stepId ?? ""; - env.ADE_HOOK_ATTEMPT_ID = hookArgs.attemptId ?? ""; - env.ADE_HOOK_SESSION_ID = hookArgs.sessionId ?? ""; - env.ADE_HOOK_REASON = hookArgs.reason; - env.ADE_HOOK_TRIGGER = hookArgs.triggerSource; - env.ADE_HOOK_MISSION_ID = missionId ?? ""; - env.ADE_HOOK_METADATA_JSON = JSON.stringify({ - event: hookArgs.event, - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - missionId, - reason: hookArgs.reason, - triggerSource: hookArgs.triggerSource, - occurredAt, - ...(hookArgs.metadata ?? {}) - }); - - void ctx.hookCommandRunner({ - command: hook.command, - cwd: ctx.projectRoot ?? process.cwd(), - timeoutMs: hook.timeoutMs, - env - }).then((result) => { - const stdoutPreview = clipHookLogText(result.stdout); - const stderrPreview = clipHookLogText(result.stderr); - const success = result.spawnError == null && !result.timedOut && result.exitCode === 0; - const logPayload = { - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - ...runtimeEventBase, - exitCode: result.exitCode, - signal: result.signal, - timedOut: result.timedOut, - durationMs: result.durationMs, - stdoutPreview, - stderrPreview, - spawnError: result.spawnError - }; - if (success) { - ctx.logger.info("ai_orchestrator.hook_execution_succeeded", logPayload); - } else { - ctx.logger.warn("ai_orchestrator.hook_execution_failed", logPayload); - } - deps.recordRuntimeEvent({ - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - eventType: "progress", - eventKey: `hook_result:${hookArgs.event}:${hookArgs.attemptId ?? hookArgs.stepId ?? "none"}:${Date.now()}`, - payload: { - ...runtimeEventBase, - phase: success ? "succeeded" : "failed", - success, - exitCode: result.exitCode, - signal: result.signal, - timedOut: result.timedOut, - durationMs: result.durationMs, - stdoutPreview, - stderrPreview, - spawnError: result.spawnError, - ...(hookArgs.metadata ?? {}) - } - }); - }).catch((error) => { - const errorMessage = error instanceof Error ? error.message : String(error); - ctx.logger.warn("ai_orchestrator.hook_execution_failed", { - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - ...runtimeEventBase, - error: errorMessage - }); - deps.recordRuntimeEvent({ - runId: hookArgs.runId, - stepId: hookArgs.stepId ?? null, - attemptId: hookArgs.attemptId ?? null, - sessionId: hookArgs.sessionId ?? null, - eventType: "progress", - eventKey: `hook_result:${hookArgs.event}:${hookArgs.attemptId ?? hookArgs.stepId ?? "none"}:${Date.now()}`, - payload: { - ...runtimeEventBase, - phase: "failed", - success: false, - error: errorMessage, - ...(hookArgs.metadata ?? {}) - } - }); - }); -} - -export function maybeDispatchTeammateIdleHookCtx( - ctx: OrchestratorContext, - idleArgs: { - runId: string; - stepId: string; - attemptId: string; - sessionId?: string | null; - previousState: OrchestratorWorkerStatus | null; - nextState: OrchestratorWorkerStatus; - reason: string; - triggerSource: "runtime_signal" | "health_sweep"; - runtimeState?: TerminalRuntimeState | null; - preview?: string | null; - laneId?: string | null; - }, - deps: HookDispatchDeps -): void { - if (idleArgs.nextState !== "idle" && idleArgs.nextState !== "waiting_input") return; - if (idleArgs.previousState === idleArgs.nextState) return; - dispatchOrchestratorHookCtx(ctx, { - event: "TeammateIdle", - runId: idleArgs.runId, - stepId: idleArgs.stepId, - attemptId: idleArgs.attemptId, - sessionId: idleArgs.sessionId ?? null, - reason: idleArgs.reason, - triggerSource: idleArgs.triggerSource, - metadata: { - previousState: idleArgs.previousState, - nextState: idleArgs.nextState, - runtimeState: idleArgs.runtimeState ?? null, - laneId: idleArgs.laneId ?? null, - preview: clipHookLogText(idleArgs.preview) - } - }, deps); -} diff --git a/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts b/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts deleted file mode 100644 index 6080d4b5a..000000000 --- a/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts +++ /dev/null @@ -1,892 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import type { - CoordinatorCheckpoint, - MissionCloseoutRequirement, - MissionCoordinatorAvailability, - MissionStateDecision, - MissionStateDocument, - MissionStateDocumentPatch, - MissionFinalizationPolicy, - MissionFinalizationState, - MissionStateIssue, - MissionStatePendingIntervention, - MissionRetrospective, - OrchestratorReflectionEntry, - MissionStateProgress, - MissionStateStepOutcome, - MissionStateStepOutcomePartial, -} from "../../../shared/types"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { isRecord, nowIso } from "./orchestratorContext"; - -const COORDINATOR_CHECKPOINT_VERSION = 1; -const MAX_DECISIONS = 30; -const MAX_STEP_OUTCOMES = 40; -const MAX_ACTIVE_ISSUES = 20; -const MAX_PENDING_INTERVENTIONS = 10; -const MAX_CHECKPOINT_SUMMARY_CHARS = 8_000; -const MISSION_STATE_READ_PENDING_WRITE_TIMEOUT_MS = 500; - -const missionStateWriteQueues = new Map<string, Promise<MissionStateDocument>>(); -const coordinatorCheckpointWriteQueues = new Map<string, Promise<CoordinatorCheckpointDocument>>(); - -export type CoordinatorCheckpointDocument = CoordinatorCheckpoint; - -const stringOr = (value: unknown, fallback = ""): string => - typeof value === "string" ? value : fallback; - -const nullableString = (value: unknown): string | null => - value === null ? null : stringOr(value).trim() || null; - -const nullableBool = (value: unknown): boolean | null => - typeof value === "boolean" ? value : null; - -const clampNonNegativeInt = (value: unknown, fallback = 0): number => { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return fallback; - return Math.max(0, Math.floor(parsed)); -}; - -const normalizeStringArray = (value: unknown, max = 200): string[] => { - if (!Array.isArray(value)) return []; - const out: string[] = []; - const seen = new Set<string>(); - for (const entry of value) { - if (out.length >= max) break; - const normalized = String(entry ?? "").trim(); - if (!normalized.length || seen.has(normalized)) continue; - seen.add(normalized); - out.push(normalized); - } - return out; -}; - -const trimSummary = (value: string): string => value.trim().slice(0, 200); - -function missionStateQueueKey(projectRoot: string, runId: string): string { - return `${projectRoot}::${runId}`; -} - -async function waitForPendingMissionStateWrite( - pending: Promise<MissionStateDocument> -): Promise<void> { - let timeout: NodeJS.Timeout | null = null; - try { - await Promise.race([ - pending.then( - () => undefined, - () => undefined - ), - new Promise<void>((resolve) => { - timeout = setTimeout(resolve, MISSION_STATE_READ_PENDING_WRITE_TIMEOUT_MS); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -} - -export function getMissionStateDocumentPath(projectRoot: string, runId: string): string { - return path.join(resolveAdeLayout(projectRoot).missionStateDir, `mission-state-${runId}.json`); -} - -export function getCoordinatorCheckpointPath(projectRoot: string, runId: string): string { - return path.join(resolveAdeLayout(projectRoot).missionStateDir, `coordinator-checkpoint-${runId}.json`); -} - -function normalizeProgress(value: unknown): MissionStateProgress { - const raw = isRecord(value) ? value : {}; - return { - currentPhase: stringOr(raw.currentPhase, "unknown").trim() || "unknown", - completedSteps: clampNonNegativeInt(raw.completedSteps, 0), - totalSteps: clampNonNegativeInt(raw.totalSteps, 0), - activeWorkers: normalizeStringArray(raw.activeWorkers), - blockedSteps: normalizeStringArray(raw.blockedSteps), - failedSteps: normalizeStringArray(raw.failedSteps), - }; -} - -function normalizeStepOutcome(value: unknown): MissionStateStepOutcome | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const stepKey = stringOr(raw.stepKey).trim(); - if (!stepKey.length) return null; - const status = - raw.status === "succeeded" || raw.status === "failed" || raw.status === "skipped" || raw.status === "in_progress" - ? raw.status - : "in_progress"; - const testsRunRaw = isRecord(raw.testsRun) ? raw.testsRun : null; - const testsRun = - testsRunRaw - ? { - passed: clampNonNegativeInt(testsRunRaw.passed, 0), - failed: clampNonNegativeInt(testsRunRaw.failed, 0), - skipped: clampNonNegativeInt(testsRunRaw.skipped, 0), - } - : undefined; - const validationRaw = isRecord(raw.validation) ? raw.validation : null; - const validation = - validationRaw - ? (() => { - const verdict: "pass" | "fail" | null = - validationRaw.verdict === "pass" || validationRaw.verdict === "fail" || validationRaw.verdict === null - ? validationRaw.verdict - : null; - return { - verdict, - findings: normalizeStringArray(validationRaw.findings, 40), - }; - })() - : undefined; - return { - stepKey, - stepName: stringOr(raw.stepName, stepKey).trim() || stepKey, - phase: stringOr(raw.phase, "unknown").trim() || "unknown", - status, - summary: trimSummary(stringOr(raw.summary)), - filesChanged: normalizeStringArray(raw.filesChanged, 50), - ...(testsRun ? { testsRun } : {}), - ...(validation ? { validation } : {}), - warnings: normalizeStringArray(raw.warnings, 40), - completedAt: raw.completedAt === null ? null : stringOr(raw.completedAt).trim() || null, - }; -} - -function normalizeDecision(value: unknown): MissionStateDecision | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const timestamp = stringOr(raw.timestamp).trim(); - const decision = stringOr(raw.decision).trim(); - const rationale = stringOr(raw.rationale).trim(); - const context = stringOr(raw.context).trim(); - if (!timestamp || !decision) return null; - return { timestamp, decision, rationale, context }; -} - -function normalizeIssue(value: unknown): MissionStateIssue | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const id = stringOr(raw.id).trim(); - if (!id.length) return null; - return { - id, - severity: raw.severity === "low" || raw.severity === "medium" || raw.severity === "high" ? raw.severity : "medium", - description: stringOr(raw.description).trim(), - affectedSteps: normalizeStringArray(raw.affectedSteps), - status: raw.status === "open" || raw.status === "mitigated" || raw.status === "resolved" ? raw.status : "open", - }; -} - -function normalizePendingIntervention(value: unknown): MissionStatePendingIntervention | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const id = stringOr(raw.id).trim(); - if (!id.length) return null; - return { - id, - type: stringOr(raw.type).trim() || "unknown", - title: stringOr(raw.title).trim() || id, - createdAt: stringOr(raw.createdAt).trim() || nowIso(), - }; -} - -function normalizeCloseoutRequirement(value: unknown): MissionCloseoutRequirement | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const key = stringOr(raw.key).trim(); - const label = stringOr(raw.label).trim(); - if (!key.length || !label.length) return null; - return { - key: key as MissionCloseoutRequirement["key"], - label, - required: raw.required === true, - status: - raw.status === "present" || raw.status === "missing" || raw.status === "waived" - ? raw.status - : "missing", - detail: nullableString(raw.detail), - artifactId: nullableString(raw.artifactId), - uri: nullableString(raw.uri), - source: - raw.source === "declared" || raw.source === "discovered" || raw.source === "runtime" || raw.source === "waiver" - ? raw.source - : "runtime", - }; -} - -function normalizeFinalizationPolicy(value: unknown): MissionFinalizationPolicy | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const kind = - raw.kind === "disabled" || raw.kind === "manual" || raw.kind === "integration" || raw.kind === "per-lane" || raw.kind === "queue" || raw.kind === "result_lane" - ? raw.kind - : null; - if (!kind) return null; - const prDepth = - raw.prDepth === "propose-only" || raw.prDepth === "resolve-conflicts" || raw.prDepth === "open-and-comment" - ? raw.prDepth - : null; - return { - kind, - targetBranch: nullableString(raw.targetBranch), - draft: nullableBool(raw.draft), - prDepth, - autoRebase: nullableBool(raw.autoRebase), - ciGating: nullableBool(raw.ciGating), - autoLand: nullableBool(raw.autoLand), - autoResolveConflicts: nullableBool(raw.autoResolveConflicts), - archiveLaneOnLand: nullableBool(raw.archiveLaneOnLand), - mergeMethod: - raw.mergeMethod === "merge" || raw.mergeMethod === "squash" || raw.mergeMethod === "rebase" - ? raw.mergeMethod - : null, - conflictResolverModel: nullableString(raw.conflictResolverModel), - reasoningEffort: nullableString(raw.reasoningEffort), - description: nullableString(raw.description), - }; -} - -function normalizeFinalizationState(value: unknown): MissionFinalizationState | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const policy = normalizeFinalizationPolicy(raw.policy); - if (!policy) return null; - const VALID_FINALIZATION_STATUSES = new Set([ - "idle", "finalizing", "creating_pr", "landing_queue", - "resolving_integration_conflicts", "resolving_queue_conflicts", "waiting_for_green", - "awaiting_operator_review", "posting_review_comment", "finalization_failed", "completed", - ]); - const VALID_WAIT_REASONS = new Set(["ci", "review", "merge_conflict", "resolver_failed", "merge_blocked", "manual", "canceled"]); - const status = VALID_FINALIZATION_STATUSES.has(raw.status as string) - ? (raw.status as MissionFinalizationState["status"]) - : "idle"; - const waitReason = VALID_WAIT_REASONS.has(raw.waitReason as string) - ? (raw.waitReason as MissionFinalizationState["waitReason"]) - : null; - return { - policy, - status, - executionComplete: raw.executionComplete === true, - contractSatisfied: raw.contractSatisfied === true, - blocked: raw.blocked === true, - blockedReason: nullableString(raw.blockedReason), - summary: nullableString(raw.summary), - detail: nullableString(raw.detail), - resolverJobId: nullableString(raw.resolverJobId), - integrationLaneId: nullableString(raw.integrationLaneId), - resultLaneId: nullableString(raw.resultLaneId), - queueGroupId: nullableString(raw.queueGroupId), - queueId: nullableString(raw.queueId), - activePrId: nullableString(raw.activePrId), - waitReason, - proposalUrl: nullableString(raw.proposalUrl), - prUrls: normalizeStringArray(raw.prUrls, 20), - reviewStatus: nullableString(raw.reviewStatus), - mergeReadiness: nullableString(raw.mergeReadiness), - requirements: Array.isArray(raw.requirements) - ? raw.requirements - .map((entry) => normalizeCloseoutRequirement(entry)) - .filter((entry): entry is MissionCloseoutRequirement => Boolean(entry)) - : [], - warnings: normalizeStringArray(raw.warnings, 40), - updatedAt: stringOr(raw.updatedAt).trim() || nowIso(), - startedAt: nullableString(raw.startedAt), - completedAt: nullableString(raw.completedAt), - }; -} - -function normalizeCoordinatorAvailability(value: unknown): MissionCoordinatorAvailability | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const mode = - raw.mode === "offline" || raw.mode === "consult_only" || raw.mode === "continuation_required" - ? raw.mode - : null; - const summary = stringOr(raw.summary).trim(); - if (!mode || !summary.length) return null; - return { - available: raw.available === true, - mode, - summary, - detail: nullableString(raw.detail), - delegation: isRecord(raw.delegation) - ? { - contractId: nullableString(raw.delegation.contractId), - workerIntent: - raw.delegation.workerIntent === "planner" - || raw.delegation.workerIntent === "implementation" - || raw.delegation.workerIntent === "validation" - || raw.delegation.workerIntent === "specialist" - || raw.delegation.workerIntent === "subagent" - || raw.delegation.workerIntent === "parallel_subtasks" - || raw.delegation.workerIntent === "recovery" - ? raw.delegation.workerIntent - : null, - mode: - raw.delegation.mode === "exclusive" - || raw.delegation.mode === "bounded_parallel" - || raw.delegation.mode === "recovery" - ? raw.delegation.mode - : null, - status: - raw.delegation.status === "launching" - || raw.delegation.status === "active" - || raw.delegation.status === "completed" - || raw.delegation.status === "failed" - || raw.delegation.status === "launch_failed" - || raw.delegation.status === "recovering" - || raw.delegation.status === "blocked" - || raw.delegation.status === "canceled" - ? raw.delegation.status - : null, - scopeKey: nullableString(raw.delegation.scopeKey), - scopeLabel: nullableString(raw.delegation.scopeLabel), - activeWorkerIds: normalizeStringArray(raw.delegation.activeWorkerIds, 40), - updatedAt: stringOr(raw.delegation.updatedAt).trim() || nowIso(), - } - : null, - updatedAt: stringOr(raw.updatedAt).trim() || nowIso(), - }; -} - - -function normalizeReflectionEntry(value: unknown): OrchestratorReflectionEntry | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const id = stringOr(raw.id).trim(); - const missionId = stringOr(raw.missionId).trim(); - const runId = stringOr(raw.runId).trim(); - const observation = stringOr(raw.observation).trim(); - if (!id || !missionId || !runId || !observation) return null; - const signalTypeRaw = stringOr(raw.signalType).trim(); - const signalType = signalTypeRaw === "wish" || signalTypeRaw === "frustration" || signalTypeRaw === "idea" || signalTypeRaw === "pattern" || signalTypeRaw === "limitation" - ? signalTypeRaw - : "idea"; - return { - id, - projectId: stringOr(raw.projectId).trim(), - missionId, - runId, - stepId: stringOr(raw.stepId).trim() || null, - attemptId: stringOr(raw.attemptId).trim() || null, - agentRole: stringOr(raw.agentRole).trim() || "unknown", - phase: stringOr(raw.phase).trim() || "unknown", - signalType, - observation, - recommendation: stringOr(raw.recommendation).trim(), - context: stringOr(raw.context).trim(), - occurredAt: stringOr(raw.occurredAt).trim() || nowIso(), - createdAt: stringOr(raw.createdAt).trim() || nowIso(), - schemaVersion: 1, - }; -} - -function normalizeRetrospective(value: unknown): MissionRetrospective | null { - const raw = isRecord(value) ? value : null; - if (!raw) return null; - const id = stringOr(raw.id).trim(); - const missionId = stringOr(raw.missionId).trim(); - const runId = stringOr(raw.runId).trim(); - if (!id || !missionId || !runId) return null; - const finalStatusRaw = stringOr(raw.finalStatus).trim(); - const finalStatus = finalStatusRaw === "queued" || finalStatusRaw === "bootstrapping" || finalStatusRaw === "active" || finalStatusRaw === "paused" || finalStatusRaw === "completing" || finalStatusRaw === "succeeded" || finalStatusRaw === "failed" || finalStatusRaw === "canceled" ? finalStatusRaw : "failed"; - return { - id, - missionId, - runId, - generatedAt: stringOr(raw.generatedAt).trim() || nowIso(), - schemaVersion: 1, - finalStatus, - wins: normalizeStringArray(raw.wins, 30), - failures: normalizeStringArray(raw.failures, 30), - unresolvedRisks: normalizeStringArray(raw.unresolvedRisks, 30), - followUpActions: normalizeStringArray(raw.followUpActions, 30), - topPainPoints: normalizeStringArray(raw.topPainPoints, 30), - topImprovements: normalizeStringArray(raw.topImprovements, 30), - patternsToCapture: normalizeStringArray(raw.patternsToCapture, 30), - estimatedImpact: stringOr(raw.estimatedImpact).trim(), - changelog: Array.isArray(raw.changelog) - ? raw.changelog - .filter( - (x): x is MissionRetrospective["changelog"][number] => - isRecord(x) && - typeof x.previousPainPoint === "string" && - typeof x.currentState === "string" && - (x.status === "resolved" || x.status === "still_open" || x.status === "worsened"), - ) - .map((x) => { - const previousPainScore = Number(x.previousPainScore); - const currentPainScore = Number(x.currentPainScore); - return { - previousPainPoint: String(x.previousPainPoint), - status: x.status as MissionRetrospective["changelog"][number]["status"], - currentState: String(x.currentState), - ...(typeof x.fixApplied === "string" && x.fixApplied.trim() ? { fixApplied: x.fixApplied.trim() } : {}), - ...(typeof x.sourceRetrospectiveId === "string" && x.sourceRetrospectiveId.trim().length - ? { sourceRetrospectiveId: x.sourceRetrospectiveId.trim() } - : {}), - ...(typeof x.sourceMissionId === "string" && x.sourceMissionId.trim().length - ? { sourceMissionId: x.sourceMissionId.trim() } - : {}), - ...(typeof x.sourceRunId === "string" && x.sourceRunId.trim().length - ? { sourceRunId: x.sourceRunId.trim() } - : {}), - ...(Number.isFinite(previousPainScore) ? { previousPainScore } : {}), - ...(Number.isFinite(currentPainScore) ? { currentPainScore } : {}), - }; - }) - : [], - }; -} - -function normalizeDocument(rawDoc: unknown): MissionStateDocument | null { - const raw = isRecord(rawDoc) ? rawDoc : null; - if (!raw) return null; - if (Number(raw.schemaVersion) !== 1) return null; - const missionId = stringOr(raw.missionId).trim(); - const runId = stringOr(raw.runId).trim(); - if (!missionId.length || !runId.length) return null; - const stepOutcomes = Array.isArray(raw.stepOutcomes) - ? raw.stepOutcomes - .map((entry) => normalizeStepOutcome(entry)) - .filter((entry): entry is MissionStateStepOutcome => Boolean(entry)) - : []; - const decisions = Array.isArray(raw.decisions) - ? raw.decisions - .map((entry) => normalizeDecision(entry)) - .filter((entry): entry is MissionStateDecision => Boolean(entry)) - : []; - const activeIssues = Array.isArray(raw.activeIssues) - ? raw.activeIssues - .map((entry) => normalizeIssue(entry)) - .filter((entry): entry is MissionStateIssue => Boolean(entry)) - : []; - const pendingInterventions = Array.isArray(raw.pendingInterventions) - ? raw.pendingInterventions - .map((entry) => normalizePendingIntervention(entry)) - .filter((entry): entry is MissionStatePendingIntervention => Boolean(entry)) - : []; - const modifiedFiles = normalizeStringArray(raw.modifiedFiles); - const finalization = normalizeFinalizationState(raw.finalization); - const coordinatorAvailability = normalizeCoordinatorAvailability(raw.coordinatorAvailability); - return { - schemaVersion: 1, - missionId, - runId, - goal: stringOr(raw.goal).trim(), - updatedAt: stringOr(raw.updatedAt).trim() || nowIso(), - progress: normalizeProgress(raw.progress), - stepOutcomes: stepOutcomes.slice(-MAX_STEP_OUTCOMES), - decisions: decisions.slice(-MAX_DECISIONS), - activeIssues: activeIssues.slice(-MAX_ACTIVE_ISSUES), - modifiedFiles, - pendingInterventions: pendingInterventions.slice(-MAX_PENDING_INTERVENTIONS), - finalization, - coordinatorAvailability, - reflections: Array.isArray(raw.reflections) - ? raw.reflections.map((entry) => normalizeReflectionEntry(entry)).filter((entry): entry is OrchestratorReflectionEntry => Boolean(entry)).slice(-200) - : [], - latestRetrospective: normalizeRetrospective(raw.latestRetrospective), - }; -} - -function normalizeCoordinatorCheckpoint(rawDoc: unknown): CoordinatorCheckpointDocument | null { - const raw = isRecord(rawDoc) ? rawDoc : null; - if (!raw) return null; - const runId = stringOr(raw.runId).trim(); - const missionId = stringOr(raw.missionId).trim(); - if (!runId.length || !missionId.length) return null; - const lastEventTimestampRaw = raw.lastEventTimestamp; - const lastEventTimestamp = - typeof lastEventTimestampRaw === "string" && lastEventTimestampRaw.trim().length > 0 - ? lastEventTimestampRaw.trim() - : null; - return { - version: COORDINATOR_CHECKPOINT_VERSION, - runId, - missionId, - conversationSummary: stringOr(raw.conversationSummary).trim().slice(0, MAX_CHECKPOINT_SUMMARY_CHARS), - lastEventTimestamp, - turnCount: clampNonNegativeInt(raw.turnCount, 0), - compactionCount: clampNonNegativeInt(raw.compactionCount, 0), - savedAt: stringOr(raw.savedAt).trim() || nowIso(), - }; -} - -function mergeStepOutcomeUpdates( - current: MissionStateStepOutcome, - updates: MissionStateStepOutcomePartial -): MissionStateStepOutcome { - const testsRun = (() => { - if (updates.testsRun === undefined) return current.testsRun; - const existing = current.testsRun ?? { passed: 0, failed: 0, skipped: 0 }; - return { - passed: clampNonNegativeInt(updates.testsRun.passed, existing.passed), - failed: clampNonNegativeInt(updates.testsRun.failed, existing.failed), - skipped: clampNonNegativeInt(updates.testsRun.skipped, existing.skipped), - }; - })(); - const validation = (() => { - if (updates.validation === undefined) return current.validation; - const existing = current.validation ?? { verdict: null, findings: [] }; - return { - verdict: - updates.validation.verdict === "pass" || updates.validation.verdict === "fail" || updates.validation.verdict === null - ? updates.validation.verdict - : existing.verdict, - findings: updates.validation.findings ? normalizeStringArray(updates.validation.findings) : existing.findings, - }; - })(); - const status = - updates.status === "succeeded" || updates.status === "failed" || updates.status === "skipped" || updates.status === "in_progress" - ? updates.status - : current.status; - return { - ...current, - stepName: updates.stepName ? updates.stepName.trim() || current.stepName : current.stepName, - phase: updates.phase ? updates.phase.trim() || current.phase : current.phase, - status, - summary: updates.summary !== undefined ? trimSummary(updates.summary) : current.summary, - filesChanged: updates.filesChanged ? normalizeStringArray(updates.filesChanged, 50) : current.filesChanged, - warnings: updates.warnings ? normalizeStringArray(updates.warnings, 40) : current.warnings, - completedAt: updates.completedAt !== undefined ? updates.completedAt : current.completedAt, - ...(testsRun ? { testsRun } : {}), - ...(validation ? { validation } : {}), - }; -} - -function createDefaultStepOutcome(stepKey: string): MissionStateStepOutcome { - return { - stepKey, - stepName: stepKey, - phase: "unknown", - status: "in_progress", - summary: "", - filesChanged: [], - warnings: [], - completedAt: null, - }; -} - -function applyPatch(doc: MissionStateDocument, patch: MissionStateDocumentPatch): MissionStateDocument { - const next: MissionStateDocument = { - ...doc, - progress: { ...doc.progress }, - stepOutcomes: [...doc.stepOutcomes], - decisions: [...doc.decisions], - activeIssues: [...doc.activeIssues], - modifiedFiles: [...doc.modifiedFiles], - pendingInterventions: [...doc.pendingInterventions], - finalization: doc.finalization ?? null, - coordinatorAvailability: doc.coordinatorAvailability ?? null, - reflections: [...(doc.reflections ?? [])], - latestRetrospective: doc.latestRetrospective ?? null, - updatedAt: nowIso(), - }; - - if (patch.updateProgress) { - const progress = patch.updateProgress; - if (progress.currentPhase !== undefined) next.progress.currentPhase = stringOr(progress.currentPhase, "unknown").trim() || "unknown"; - if (progress.completedSteps !== undefined) next.progress.completedSteps = clampNonNegativeInt(progress.completedSteps, next.progress.completedSteps); - if (progress.totalSteps !== undefined) next.progress.totalSteps = clampNonNegativeInt(progress.totalSteps, next.progress.totalSteps); - if (progress.activeWorkers !== undefined) next.progress.activeWorkers = normalizeStringArray(progress.activeWorkers); - if (progress.blockedSteps !== undefined) next.progress.blockedSteps = normalizeStringArray(progress.blockedSteps); - if (progress.failedSteps !== undefined) next.progress.failedSteps = normalizeStringArray(progress.failedSteps); - } - - if (patch.addStepOutcome) { - const normalized = normalizeStepOutcome(patch.addStepOutcome); - if (normalized) { - const idx = next.stepOutcomes.findIndex((entry) => entry.stepKey === normalized.stepKey); - if (idx >= 0) { - next.stepOutcomes[idx] = mergeStepOutcomeUpdates(next.stepOutcomes[idx]!, normalized); - } else { - next.stepOutcomes.push(normalized); - } - } - } - - if (patch.updateStepOutcome) { - const stepKey = patch.updateStepOutcome.stepKey.trim(); - if (stepKey.length) { - const idx = next.stepOutcomes.findIndex((entry) => entry.stepKey === stepKey); - const current = idx >= 0 ? next.stepOutcomes[idx]! : createDefaultStepOutcome(stepKey); - const merged = mergeStepOutcomeUpdates(current, patch.updateStepOutcome.updates); - if (idx >= 0) { - next.stepOutcomes[idx] = merged; - } else { - next.stepOutcomes.push(merged); - } - } - } - - if (patch.addDecision) { - const normalized = normalizeDecision(patch.addDecision); - if (normalized) { - next.decisions.push(normalized); - } - } - - if (patch.addIssue) { - const normalized = normalizeIssue(patch.addIssue); - if (normalized) { - const idx = next.activeIssues.findIndex((entry) => entry.id === normalized.id); - if (idx >= 0) { - next.activeIssues[idx] = normalized; - } else { - next.activeIssues.push(normalized); - } - } - } - - if (patch.resolveIssue) { - const issueId = patch.resolveIssue.id.trim(); - const idx = next.activeIssues.findIndex((entry) => entry.id === issueId); - if (idx >= 0) { - next.activeIssues[idx] = { - ...next.activeIssues[idx]!, - status: "resolved", - }; - } - const resolution = patch.resolveIssue.resolution.trim(); - if (issueId.length && resolution.length) { - next.decisions.push({ - timestamp: nowIso(), - decision: `Resolved issue ${issueId}`, - rationale: resolution, - context: "Issue resolution", - }); - } - } - - if (patch.pendingInterventions) { - next.pendingInterventions = patch.pendingInterventions - .map((entry) => normalizePendingIntervention(entry)) - .filter((entry): entry is MissionStatePendingIntervention => Boolean(entry)) - .slice(-MAX_PENDING_INTERVENTIONS); - } - - if (patch.finalization !== undefined) { - next.finalization = patch.finalization ? normalizeFinalizationState(patch.finalization) : null; - } - - if (patch.coordinatorAvailability !== undefined) { - next.coordinatorAvailability = patch.coordinatorAvailability - ? normalizeCoordinatorAvailability(patch.coordinatorAvailability) - : null; - } - - if (patch.reflections) { - next.reflections = patch.reflections - .map((entry) => normalizeReflectionEntry(entry)) - .filter((entry): entry is OrchestratorReflectionEntry => Boolean(entry)) - .slice(-200); - } - - if (patch.latestRetrospective !== undefined) { - next.latestRetrospective = patch.latestRetrospective ? normalizeRetrospective(patch.latestRetrospective) : null; - } - - next.stepOutcomes = next.stepOutcomes.slice(-MAX_STEP_OUTCOMES); - next.decisions = next.decisions.slice(-MAX_DECISIONS); - next.activeIssues = next.activeIssues.slice(-MAX_ACTIVE_ISSUES); - next.modifiedFiles = normalizeStringArray(next.stepOutcomes.flatMap((entry) => entry.filesChanged), 150); - return next; -} - -async function loadDocumentFromDisk(filePath: string): Promise<MissionStateDocument | null> { - try { - const raw = await fs.readFile(filePath, "utf8"); - return normalizeDocument(JSON.parse(raw)); - } catch { - return null; - } -} - -async function writeDocumentToDisk(filePath: string, doc: unknown): Promise<void> { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - await fs.writeFile(tempPath, JSON.stringify(doc, null, 2), "utf8"); - await fs.rename(tempPath, filePath); -} - -export function createInitialMissionStateDocument(args: { - missionId: string; - runId: string; - goal: string; - progress?: Partial<MissionStateProgress>; -}): MissionStateDocument { - return { - schemaVersion: 1, - missionId: args.missionId, - runId: args.runId, - goal: args.goal, - updatedAt: nowIso(), - progress: normalizeProgress(args.progress), - stepOutcomes: [], - decisions: [], - activeIssues: [], - modifiedFiles: [], - pendingInterventions: [], - reflections: [], - latestRetrospective: null, - }; -} - -export async function updateMissionStateDocument(args: { - projectRoot: string; - missionId: string; - runId: string; - goal: string; - patch: MissionStateDocumentPatch; - initialProgress?: Partial<MissionStateProgress>; -}): Promise<MissionStateDocument> { - const filePath = getMissionStateDocumentPath(args.projectRoot, args.runId); - const key = missionStateQueueKey(args.projectRoot, args.runId); - const previous = missionStateWriteQueues.get(key) ?? Promise.resolve( - createInitialMissionStateDocument({ - missionId: args.missionId, - runId: args.runId, - goal: args.goal, - progress: args.initialProgress, - }) - ); - const nextJob = previous - .catch(() => - createInitialMissionStateDocument({ - missionId: args.missionId, - runId: args.runId, - goal: args.goal, - progress: args.initialProgress, - }) - ) - .then(async () => { - const existing = await loadDocumentFromDisk(filePath); - const base = - existing ?? - createInitialMissionStateDocument({ - missionId: args.missionId, - runId: args.runId, - goal: args.goal, - progress: args.initialProgress, - }); - const nextDoc = applyPatch(base, args.patch); - await writeDocumentToDisk(filePath, nextDoc); - return nextDoc; - }); - missionStateWriteQueues.set(key, nextJob); - try { - return await nextJob; - } finally { - if (missionStateWriteQueues.get(key) === nextJob) { - missionStateWriteQueues.delete(key); - } - } -} - -export async function readMissionStateDocument(args: { - projectRoot: string; - runId: string; -}): Promise<MissionStateDocument | null> { - const key = missionStateQueueKey(args.projectRoot, args.runId); - const pending = missionStateWriteQueues.get(key); - if (pending) { - await waitForPendingMissionStateWrite(pending); - } - return loadDocumentFromDisk(getMissionStateDocumentPath(args.projectRoot, args.runId)); -} - -export async function writeCoordinatorCheckpoint( - projectRoot: string, - runId: string, - checkpoint: CoordinatorCheckpointDocument -): Promise<CoordinatorCheckpointDocument> { - const filePath = getCoordinatorCheckpointPath(projectRoot, runId); - const key = missionStateQueueKey(projectRoot, runId); - const normalizedInput = normalizeCoordinatorCheckpoint({ - ...checkpoint, - version: COORDINATOR_CHECKPOINT_VERSION, - runId, - }); - if (!normalizedInput) { - throw new Error(`Invalid coordinator checkpoint payload for run '${runId}'.`); - } - const previous = coordinatorCheckpointWriteQueues.get(key) ?? Promise.resolve(normalizedInput); - const nextJob = previous - .catch(() => normalizedInput) - .then(async () => { - const normalized = normalizeCoordinatorCheckpoint(normalizedInput); - if (!normalized) { - throw new Error(`Unable to normalize coordinator checkpoint for run '${runId}'.`); - } - await writeDocumentToDisk(filePath, normalized); - return normalized; - }); - coordinatorCheckpointWriteQueues.set(key, nextJob); - try { - return await nextJob; - } finally { - if (coordinatorCheckpointWriteQueues.get(key) === nextJob) { - coordinatorCheckpointWriteQueues.delete(key); - } - } -} - -export async function readCoordinatorCheckpoint( - projectRoot: string, - runId: string -): Promise<CoordinatorCheckpointDocument | null> { - const key = missionStateQueueKey(projectRoot, runId); - const pending = coordinatorCheckpointWriteQueues.get(key); - if (pending) { - try { - await pending; - } catch { - // Best-effort wait; continue reading last persisted content. - } - } - const filePath = getCoordinatorCheckpointPath(projectRoot, runId); - let raw: string; - try { - raw = await fs.readFile(filePath, "utf8"); - } catch (error) { - // ENOENT is the expected case (no checkpoint written yet) — silent. - // Any other read error is unexpected; log it. - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - console.warn(`[missionStateDoc] Failed to read coordinator checkpoint for run '${runId}':`, error); - } - return null; - } - try { - return normalizeCoordinatorCheckpoint(JSON.parse(raw)); - } catch (error) { - // File exists but could not be parsed — warn so corrupt checkpoints are visible. - console.warn(`[missionStateDoc] Corrupt coordinator checkpoint for run '${runId}' — ignoring:`, error); - return null; - } -} - -export async function deleteCoordinatorCheckpoint( - projectRoot: string, - runId: string -): Promise<void> { - const key = missionStateQueueKey(projectRoot, runId); - const pending = coordinatorCheckpointWriteQueues.get(key); - if (pending) { - try { - await pending; - } catch { - // Ignore pending write failures; cleanup should still proceed. - } - } - try { - await fs.unlink(getCoordinatorCheckpointPath(projectRoot, runId)); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - throw error; - } - } -} diff --git a/apps/desktop/src/main/services/orchestrator/modelConfigResolver.ts b/apps/desktop/src/main/services/orchestrator/modelConfigResolver.ts deleted file mode 100644 index 3e7abdfe3..000000000 --- a/apps/desktop/src/main/services/orchestrator/modelConfigResolver.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * modelConfigResolver.ts - * - * Model and call-type configuration resolution: resolves orchestrator model - * configs, call-type settings, planner models, decision timeout caps. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import type { - OrchestratorContext, - ResolvedCallTypeConfig, -} from "./orchestratorContext"; -import { - isRecord, - CALL_TYPE_DEFAULTS, - DECISION_TIMEOUT_CAP_MS_BY_HOURS, -} from "./orchestratorContext"; -import { getMissionMetadata } from "./chatMessageService"; -import type { ModelConfig, OrchestratorCallType, MissionModelConfig } from "../../../shared/types"; -import { resolveCallTypeModel, modelConfigToServiceModel } from "../../../shared/modelProfiles"; -import { resolveModelDescriptor } from "../../../shared/modelRegistry"; - -const CALL_TYPE_CONFIG_TTL_MS = 30_000; - -export function resolveCallTypeConfig( - ctx: OrchestratorContext, - missionId: string, - callType: OrchestratorCallType -): ResolvedCallTypeConfig { - const cacheKey = `${missionId}:${callType}`; - const cached = ctx.callTypeConfigCache.get(cacheKey); - if (cached && Date.now() < cached.expiresAt) return cached.config; - const config = resolveCallTypeConfigUncached(ctx, missionId, callType); - ctx.callTypeConfigCache.set(cacheKey, { config, expiresAt: Date.now() + CALL_TYPE_CONFIG_TTL_MS }); - return config; -} - -function resolveCallTypeConfigUncached( - ctx: OrchestratorContext, - missionId: string, - callType: OrchestratorCallType -): ResolvedCallTypeConfig { - const defaults = CALL_TYPE_DEFAULTS[callType]; - try { - const row = ctx.db.get<{ metadata_json: string | null }>( - "select metadata_json from missions where id = ? limit 1", - [missionId] - ); - if (row?.metadata_json) { - const metadata = JSON.parse(row.metadata_json); - const launch = isRecord(metadata.launch) ? metadata.launch : null; - - // Priority 1: Per-call-type intelligence config (most specific) - const intelligenceConfig = launch && isRecord(launch.intelligenceConfig) ? launch.intelligenceConfig : null; - if (intelligenceConfig) { - const callConfig = isRecord(intelligenceConfig[callType]) ? intelligenceConfig[callType] : null; - if (callConfig) { - const modelIdRaw = typeof callConfig.modelId === "string" ? callConfig.modelId.trim() : ""; - const descriptor = resolveModelDescriptor(modelIdRaw); - const resolvedModelId = descriptor?.id ?? defaults.model; - const resolvedDescriptor = resolveModelDescriptor(resolvedModelId); - const providerFromModel = - resolvedDescriptor?.family === "anthropic" - ? "claude" - : resolvedDescriptor?.family === "openai" - ? "codex" - : defaults.provider; - return { - provider: providerFromModel, - model: resolvedModelId, - reasoningEffort: typeof callConfig.thinkingLevel === "string" ? callConfig.thinkingLevel : defaults.reasoningEffort, - }; - } - } - } - } catch { /* ignore parse errors */ } - // Priority 3: Built-in defaults per call type - return defaults; -} - -const DEFAULT_ORCHESTRATOR_MODEL_CONFIG: ModelConfig = { - modelId: "anthropic/claude-sonnet-4-6", - provider: "anthropic", - thinkingLevel: "medium" -}; - -function resolveMissionLaunchModelConfig(metadata: Record<string, unknown>): MissionModelConfig | null { - const directModelConfig = isRecord(metadata.modelConfig) ? metadata.modelConfig : null; - if (directModelConfig) { - return directModelConfig as MissionModelConfig; - } - const launch = isRecord(metadata.launch) ? metadata.launch : null; - const launchModelConfig = launch && isRecord(launch.modelConfig) ? launch.modelConfig : null; - return launchModelConfig ? (launchModelConfig as MissionModelConfig) : null; -} - -export function resolveMissionDecisionTimeoutCapMs( - ctx: OrchestratorContext, - missionId: string -): number { - const metadata = getMissionMetadata(ctx, missionId); - const modelConfig = resolveMissionLaunchModelConfig(metadata); - const rawHours = Number(modelConfig?.decisionTimeoutCapHours ?? Number.NaN); - const normalizedHours = Number.isFinite(rawHours) ? Math.floor(rawHours) : 24; - return DECISION_TIMEOUT_CAP_MS_BY_HOURS[normalizedHours] ?? DECISION_TIMEOUT_CAP_MS_BY_HOURS[24]; -} - -export function resolveAiDecisionLikeTimeoutMs( - _ctx: OrchestratorContext, - _missionId: string -): number | null { - return null; -} - -/** Resolve a per-call-type ModelConfig from mission metadata. */ -export function resolveOrchestratorModelConfig( - ctx: OrchestratorContext, - missionId: string, - callType: OrchestratorCallType -): ModelConfig { - const metadata = getMissionMetadata(ctx, missionId); - const missionModelConfig = resolveMissionLaunchModelConfig(metadata); - - if (missionModelConfig) { - return resolveCallTypeModel( - callType, - missionModelConfig.intelligenceConfig, - missionModelConfig.orchestratorModel - ); - } - - return DEFAULT_ORCHESTRATOR_MODEL_CONFIG; -} - -/** Resolve the orchestrator model for AI decision calls. */ -export function resolveOrchestratorModel( - ctx: OrchestratorContext, - missionId: string -): string { - const config = resolveOrchestratorModelConfig(ctx, missionId, "coordinator"); - return modelConfigToServiceModel(config); -} - -export function resolveMissionModelConfig( - ctx: OrchestratorContext, - missionId: string -): MissionModelConfig | null { - const metadata = getMissionMetadata(ctx, missionId); - return resolveMissionLaunchModelConfig(metadata); -} diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts deleted file mode 100644 index 44401f94b..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts +++ /dev/null @@ -1,707 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildFullPrompt } from "./baseOrchestratorAdapter"; -import { - mapPermissionToClaude, - mapPermissionToCodex, - mergeMissionPermissionConfig, - normalizeMissionPermissions, -} from "./permissionMapping"; -import { - resolveMissionDecisionTimeoutCapMs, - resolveMissionModelConfig, - resolveOrchestratorModelConfig, -} from "./modelConfigResolver"; - -const mockState = vi.hoisted(() => ({ - resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/mock/bin/claude", source: "path" as const })), -})); - -vi.mock("../ai/claudeCodeExecutable", () => ({ - resolveClaudeCodeExecutable: mockState.resolveClaudeCodeExecutable, -})); - -import { createProviderOrchestratorAdapter } from "./providerOrchestratorAdapter"; - -describe("buildFullPrompt", () => { - it("tells planning retries not to ask again after a planner question is answered", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Plan weighted item parsing", - }, - } as any, - step: { - id: "step-1", - title: "Planning worker", - stepKey: "planning-worker", - laneId: "lane-1", - metadata: { - stepType: "planning", - phaseName: "Planning", - phaseAskQuestions: { enabled: true, maxQuestions: 1 }, - readOnlyExecution: true, - lastAnsweredPlannerQuestion: { - question: "Should missing weights default to 1?", - answer: "Yes, default missing or invalid weights to 1.", - }, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: { - content: "", - truncated: false, - }, - projectExport: { - content: "Project context body", - truncated: false, - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "codex" - ).prompt; - - expect(prompt).toContain("PLANNING QUESTION ALREADY ANSWERED"); - expect(prompt).toContain("Yes, default missing or invalid weights to 1."); - expect(prompt).toContain("do not ask another natural-language clarification"); - expect(prompt).toContain("return `report_result.plan.markdown` now"); - }); - - it("routes read-only workers to ADE result reporting instead of file writes", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Research the sidebar flow", - }, - } as any, - step: { - id: "step-1", - title: "Plan sidebar changes", - stepKey: "plan-sidebar", - laneId: "lane-1", - metadata: { - readOnlyExecution: true, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(prompt.prompt).toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).toContain("This step cannot write files."); - expect(prompt.prompt).not.toContain("PROGRESS CHECKPOINTING:"); - expect(prompt.prompt).not.toContain("STEP OUTPUT FILE:"); - }); - - it("keeps checkpoint and step output instructions for writable workers", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Implement the sidebar flow", - }, - } as any, - step: { - id: "step-1", - title: "Implement sidebar changes", - stepKey: "implement-sidebar", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(prompt.prompt).toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).toContain("PROGRESS CHECKPOINTING:"); - expect(prompt.prompt).toContain("STEP OUTPUT FILE:"); - }); - - it("removes ADE mission-tool instructions for in-process workers", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Implement the sidebar flow", - }, - } as any, - step: { - id: "step-1", - title: "Implement sidebar changes", - stepKey: "implement-sidebar", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - { workerRuntime: "in_process" } - ); - - expect(prompt.prompt).toContain("This worker is running in-process."); - expect(prompt.prompt).toContain("RUNTIME LIMITS:"); - expect(prompt.prompt).not.toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).not.toContain("ADE TOOLING:"); - }); -}); - -describe("providerOrchestratorAdapter", () => { - let projectRoot: string | null = null; - - beforeEach(() => { - mockState.resolveClaudeCodeExecutable.mockReturnValue({ path: "/mock/bin/claude", source: "path" }); - }); - - afterEach(() => { - if (projectRoot) { - fs.rmSync(projectRoot, { recursive: true, force: true }); - projectRoot = null; - } - }); - - it("passes Codex config-toml through to managed chat sessions", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - const createSession = vi.fn(async () => ({ id: "managed-session-1" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: { - createSession, - } as any, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "codex-worker", - title: "Codex worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - modelId: "openai/gpt-5.3-codex", - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession: vi.fn(), - permissionConfig: { - _providers: { - claude: "full-auto", - codex: "config-toml", - opencode: "full-auto", - codexSandbox: "workspace-write", - }, - }, - } as any); - - expect(result.status).toBe("accepted"); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - provider: "codex", - model: "gpt-5.3-codex", - modelId: "openai/gpt-5.3-codex", - title: "Codex worker", - surface: "mission", - permissionMode: "config-toml", - codexConfigSource: "config-toml", - })); - expect((result as any).launch.prompt).toContain("Your full ADE chat transcript is captured"); - expect((result as any).launch.prompt).toContain("final `### report_result` section"); - expect((result as any).launch.prompt).not.toContain("This worker runs as a bounded in-process execution"); - }); - - it("tells managed planning workers to ask through ADE chat input controls", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - const createSession = vi.fn(async () => ({ id: "managed-session-1" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: { - createSession, - } as any, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "planning", - title: "Planning", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - modelId: "openai/gpt-5.3-codex", - stepType: "planning", - readOnlyExecution: true, - phaseAskQuestions: { - enabled: true, - requiredBeforeExit: true, - }, - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession: vi.fn(), - permissionConfig: {}, - } as any); - - expect(result.status).toBe("accepted"); - const prompt = (result as any).launch.prompt as string; - expect(prompt).toContain("This planning phase requires at least one blocking user question"); - expect(prompt).toContain("Use ADE's native chat question UI"); - expect(prompt).toContain("Codex: call `request_user_input`"); - expect(prompt).toContain("Return your plan directly in the final assistant response under a `### report_result` heading"); - expect(prompt).not.toContain("Use `ask_user` before `report_result`"); - }); - - it("uses phase model thinking level for managed chat worker reasoning", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - const createSession = vi.fn(async () => ({ id: "managed-session-1" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: { - createSession, - } as any, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "codex-worker", - title: "Codex worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - modelId: "openai/gpt-5.5", - phaseModel: { - modelId: "openai/gpt-5.5", - provider: "codex", - thinkingLevel: "low", - }, - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession: vi.fn(), - permissionConfig: {}, - } as any); - - expect(result.status).toBe("accepted"); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - provider: "codex", - modelId: "openai/gpt-5.5", - title: "Codex worker", - surface: "mission", - reasoningEffort: "low", - })); - expect(result).toMatchObject({ - launch: { reasoningEffort: "low" }, - metadata: { reasoningEffort: "low" }, - }); - }); - - it("resolves the Claude executable for direct startup-command overrides", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - mockState.resolveClaudeCodeExecutable.mockReturnValue({ - path: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", - source: "path", - }); - const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-override", sessionId: "session-override" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: null, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "override-worker", - title: "Override worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - startupCommand: "diagnose the failing check", - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession, - } as any); - - expect(result.status).toBe("accepted"); - expect(mockState.resolveClaudeCodeExecutable).toHaveBeenCalledTimes(1); - expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ - command: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", - args: ["-p", expect.stringContaining("diagnose the failing check")], - startupCommand: expect.stringContaining("exec claude -p"), - })); - }); - - it("launches CLI-wrapped fallback workers without shell-only command syntax", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: null, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "codex-worker", - title: "Codex worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - modelId: "openai/gpt-5.3-codex", - phaseModel: { - modelId: "openai/gpt-5.3-codex", - provider: "codex", - thinkingLevel: "low", - }, - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession, - permissionConfig: { - _providers: { - codex: "default", - codexSandbox: "workspace-write", - }, - }, - } as any); - - expect(result.status).toBe("accepted"); - expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ - command: process.execPath, - args: expect.arrayContaining(["-e"]), - env: expect.objectContaining({ - ELECTRON_RUN_AS_NODE: "1", - ADE_MISSION_ID: "mission-1", - ADE_RUN_ID: "run-1", - ADE_STEP_ID: "step-1", - ADE_ATTEMPT_ID: "attempt-1", - ADE_DEFAULT_ROLE: "agent", - }), - 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", () => { - it("maps Codex edit to writable guarded execution", () => { - expect(mapPermissionToCodex("edit")).toEqual({ - approvalPolicy: "untrusted", - sandbox: "workspace-write", - }); - expect(mapPermissionToCodex("plan")).toEqual({ - approvalPolicy: "on-request", - sandbox: "read-only", - }); - expect(mapPermissionToCodex("default")).toEqual({ - approvalPolicy: "on-request", - sandbox: "workspace-write", - }); - }); - - it("preserves Codex full-auto and Claude accept-edits semantics", () => { - expect(mapPermissionToCodex("full-auto")).toEqual({ - approvalPolicy: "never", - sandbox: "danger-full-access", - }); - expect(mapPermissionToClaude("edit")).toBe("acceptEdits"); - }); - - it("merges raw mission overrides without resetting unrelated provider settings", () => { - const merged = mergeMissionPermissionConfig( - { - providers: { - codex: "config-toml", - claude: "edit", - }, - }, - { - inProcess: { mode: "plan" }, - }, - ); - - expect(normalizeMissionPermissions(merged)).toMatchObject({ - codex: "config-toml", - claude: "edit", - opencode: "plan", - }); - }); -}); - -function createModelConfigCtx(metadata: Record<string, unknown>) { - return { - db: { - get: () => ({ - metadata_json: JSON.stringify(metadata), - }), - }, - callTypeConfigCache: new Map(), - } as any; -} - -describe("modelConfigResolver", () => { - it("reads mission model config from launch metadata", () => { - const ctx = createModelConfigCtx({ - launch: { - modelConfig: { - orchestratorModel: { - modelId: "openai/gpt-5.3-codex", - provider: "codex", - thinkingLevel: "medium", - }, - decisionTimeoutCapHours: 12, - }, - }, - }); - - expect(resolveMissionModelConfig(ctx, "mission-1")?.orchestratorModel?.modelId).toBe("openai/gpt-5.3-codex"); - expect(resolveOrchestratorModelConfig(ctx, "mission-1", "coordinator").modelId).toBe("openai/gpt-5.3-codex"); - expect(resolveMissionDecisionTimeoutCapMs(ctx, "mission-1")).toBe(12 * 60 * 60 * 1000); - }); - - it("falls back to the legacy root model config shape", () => { - const ctx = createModelConfigCtx({ - modelConfig: { - orchestratorModel: { - modelId: "anthropic/claude-sonnet-4-6", - provider: "claude", - thinkingLevel: "medium", - }, - decisionTimeoutCapHours: 6, - }, - }); - - expect(resolveMissionModelConfig(ctx, "mission-2")?.orchestratorModel?.modelId).toBe("anthropic/claude-sonnet-4-6"); - expect(resolveMissionDecisionTimeoutCapMs(ctx, "mission-2")).toBe(6 * 60 * 60 * 1000); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts b/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts deleted file mode 100644 index 3420f7968..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts +++ /dev/null @@ -1,177 +0,0 @@ -// --------------------------------------------------------------------------- -// Runtime constants moved out of shared/types (pure types only there). -// --------------------------------------------------------------------------- - -import type { - RoleIsolationRule, - RecoveryLoopPolicy, - ContextViewPolicy, - OrchestratorContextView, - IntegrationPrPolicy, - PrDepth, - WorkerSandboxConfig, -} from "../../../shared/types"; - -/** - * The default set of role isolation rules. - * Source: Anthropic sub-agent docs recommend strict role boundaries - * to prevent context contamination between implementers and reviewers. - */ -export const DEFAULT_ROLE_ISOLATION_RULES: RoleIsolationRule[] = [ - { - mutuallyExclusive: ["implementation", "code_review"], - enforcement: "auto_correct", - reason: "Implementers must not review their own code." - }, - { - mutuallyExclusive: ["implementation", "test_review"], - enforcement: "auto_correct", - reason: "Implementers must not review their own test results." - }, - { - mutuallyExclusive: ["testing", "test_review"], - enforcement: "auto_correct", - reason: "Test authors must not review their own test results." - }, - { - mutuallyExclusive: ["code_review", "implementation"], - enforcement: "auto_correct", - reason: "Reviewers must not implement code they reviewed." - } -]; - -export const DEFAULT_RECOVERY_LOOP_POLICY: RecoveryLoopPolicy = { - enabled: true, - maxIterations: 3, - onExhaustion: "intervention", - minConfidenceDelta: 0.1, - escalateAfterStagnant: 2 -}; - -export const DEFAULT_CONTEXT_VIEW_POLICIES: Record<OrchestratorContextView, ContextViewPolicy> = { - implementation: { - view: "implementation", - readOnly: false, - includeScratchContext: true, - includeArtifacts: true, - includeCheckResults: true, - includeHandoffSummaries: true, - diffMode: "full" - }, - review: { - view: "review", - readOnly: true, - includeScratchContext: false, - includeArtifacts: true, - includeCheckResults: true, - includeHandoffSummaries: true, - diffMode: "full" - }, - test_review: { - view: "test_review", - readOnly: true, - includeScratchContext: false, - includeArtifacts: true, - includeCheckResults: true, - includeHandoffSummaries: true, - diffMode: "summary" - } -}; - -export const DEFAULT_INTEGRATION_PR_POLICY: IntegrationPrPolicy = { - enabled: false, - createIntegrationLane: true, - prDepth: "resolve-conflicts" as PrDepth, - draft: true -}; - -/** Safe-by-default worker permission fallbacks when project/mission config omits provider settings. */ -export const DEFAULT_CLAUDE_PERMISSION_MODE = "acceptEdits"; -export const DEFAULT_CODEX_APPROVAL_MODE = "auto-edit"; -export const DEFAULT_CODEX_SANDBOX_PERMISSIONS = "workspace-write"; - -/** - * Default sandbox configuration for API-model workers. - * Based on the Claude sandbox rules in .claude/hooks/sandbox.py, - * filtered to universal patterns (no project-specific integration rules). - * CLI-wrapped models (Claude, Codex) skip this — they have native sandboxing. - */ -export const DEFAULT_WORKER_SANDBOX_CONFIG: WorkerSandboxConfig = { - blockedCommands: [ - "\\brm\\s+-rf\\s+/", - "\\brm\\s+-rf\\s+~", - "\\bsudo\\b", - "\\bchmod\\s+777\\b", - "\\bcurl\\b.*\\|\\s*sh", - "\\bwget\\b.*\\|\\s*sh", - "\\beval\\b", - ">\\s*/etc/", - ">\\s*/usr/", - ">\\s*/var/", - "\\bmkfs\\b", - "\\bdd\\b\\s+if=", - "\\bshutdown\\b", - "\\breboot\\b", - ":\\(\\)\\{", - "\\breg(?:\\.exe)?\\s+(add|delete|import|load|unload|copy|save|restore)\\b", - "\\bdiskpart(?:\\.exe)?\\b", - "\\bformat(?:\\.exe)?\\s+[a-z]:", - "\\bbcdedit(?:\\.exe)?\\b", - "\\btakeown(?:\\.exe)?\\b", - ">\\s*[^\\n\\r]*[/\\\\]windows[/\\\\]system32\\b", - ">\\s*[^\\n\\r]*[/\\\\]windows[/\\\\]syswow64\\b", - ], - safeCommands: [ - "^pnpm(\\.cmd)?\\s", - "^npm(\\.cmd)?\\s", - "^yarn(\\.cmd)?\\s", - "^npx(\\.cmd)?\\s", - "^git(\\.exe)?\\s+status\\b", - "^git(\\.exe)?\\s+diff\\b", - "^git(\\.exe)?\\s+log\\b", - "^git(\\.exe)?\\s+show\\b", - "^git(\\.exe)?\\s+branch\\s*$", - "^git(\\.exe)?\\s+ls-files\\b", - "^ls\\s", - "^ls$", - "^pwd\\b", - "^echo\\s", - "^date\\b", - "^node(\\.exe)?\\s", - "^tsx(\\.cmd)?\\s", - "^vitest(\\.cmd)?\\s", - "^jest(\\.cmd)?\\s", - "^eslint(\\.cmd)?\\s", - "^prettier(\\.cmd)?\\s", - "^tsc(\\.cmd)?\\b", - "^lsof\\s", - "^ps\\s", - ], - protectedFiles: [ - "\\.env$", - "\\.env\\.", - "secrets?\\.json$", - "credentials\\.json$", - "\\.pem$", - "\\.key$", - "/\\.git/", - ], - allowedPaths: ["./"], - blockByDefault: false, -}; - -/** - * Maps known slash commands to their prompt translations. - * This ensures slash commands work correctly when invoked - * by the orchestrator via Claude CLI -p flag. - */ -export const SLASH_COMMAND_TRANSLATIONS: Record<string, { prompt: string; interactive: boolean }> = { - "/automate": { - prompt: "Run the /automate skill. This means: analyze the current project state, identify work that needs to be done based on the mission context, and execute it autonomously using agent teams. Use the Skill tool to invoke the 'automate' skill if available, otherwise carry out the automation workflow directly.", - interactive: false - }, - "/finalize": { - prompt: "Run the /finalize skill. This means: perform end-of-cycle documentation audit - scan the codebase, verify docs are up to date, update the implementation plan, and run local checks. Use the Skill tool to invoke the 'finalize' skill if available, otherwise carry out the finalization workflow directly.", - interactive: false - } -}; diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts deleted file mode 100644 index bc04cdfb3..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts +++ /dev/null @@ -1,1339 +0,0 @@ -/** - * orchestratorContext.ts - * - * Shared context type, internal types, constants, and utility functions - * used by all aiOrchestrator modules. Extracted from the monolithic - * aiOrchestratorService.ts to enable modular decomposition. - */ - -import { createHash } from "node:crypto"; -import { spawn } from "node:child_process"; -import { terminateProcessTree } from "../shared/processExecution"; -import { nowIso } from "../shared/utils"; -import type { GetModelCapabilitiesResult } from "../../../shared/types"; -import type { - MissionDetail, - MissionExecutionPolicy, - MissionLevelSettings, - PhaseCard, - MissionStepStatus, - MissionStatus, - ModelCapabilityProfile, - OrchestratorCallType, - OrchestratorExecutorKind, - OrchestratorRunGraph, - OrchestratorRuntimeEvent, - OrchestratorStepStatus, - OrchestratorWorkerState, - OrchestratorWorkerStatus, - OrchestratorPlannerProvider, - OrchestratorRuntimeQuestionLink, - TerminalRuntimeState, - UserSteeringDirective, - OrchestratorChatMessage, - OrchestratorChatThread, - OrchestratorChatThreadType, - OrchestratorChatTarget, - OrchestratorChatVisibilityMode, - OrchestratorChatDeliveryState, - OrchestratorWorkerDigest, - OrchestratorContextCheckpoint, - OrchestratorLaneDecision, - MissionMetricsConfig, - MissionMetricSample, - MissionMetricToggle, - OrchestratorThreadEvent, - AgentChatEventEnvelope, - TeamManifest, - ExecutionPlanPreview, - OrchestratorWorkerRole, - RecoveryLoopPolicy, - RecoveryLoopState, - RecoveryDiagnosisTier, - RecoveryDiagnosis, - OrchestratorContextView, - OrchestratorArtifactKind, - ModelConfig, - MissionModelConfig, - RecoveryLoopIteration, - OrchestratorTeamRuntimeState, - DagMutationEvent, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { createMissionService } from "../missions/missionService"; -import type { createOrchestratorService } from "./orchestratorService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createPrService } from "../prs/prService"; -import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; - -// ── Re-export commonly-used shared types for module convenience ── -export type { - MissionDetail, - MissionExecutionPolicy, - MissionStepStatus, - MissionStatus, - ModelCapabilityProfile, - OrchestratorExecutorKind, - OrchestratorRunGraph, - OrchestratorRuntimeEvent, - OrchestratorStepStatus, - OrchestratorWorkerState, - OrchestratorWorkerStatus, - OrchestratorPlannerProvider, - OrchestratorRuntimeQuestionLink, - TerminalRuntimeState, - UserSteeringDirective, - OrchestratorChatMessage, - OrchestratorChatThread, - OrchestratorChatThreadType, - OrchestratorChatTarget, - OrchestratorChatVisibilityMode, - OrchestratorChatDeliveryState, - OrchestratorWorkerDigest, - OrchestratorContextCheckpoint, - OrchestratorLaneDecision, - MissionMetricsConfig, - MissionMetricSample, - MissionMetricToggle, - OrchestratorThreadEvent, - AgentChatEventEnvelope, - TeamManifest, - ExecutionPlanPreview, - OrchestratorWorkerRole, - RecoveryLoopPolicy, - RecoveryLoopState, - RecoveryDiagnosisTier, - RecoveryDiagnosis, - OrchestratorContextView, - OrchestratorArtifactKind, - ModelConfig, - MissionModelConfig, - RecoveryLoopIteration, - OrchestratorTeamRuntimeState, - DagMutationEvent, -}; - -// ── Internal Types ────────────────────────────────────────────────── - -export type MissionRunStartArgs = { - missionId: string; - queueClaimToken?: string | null; - runMode?: "autopilot" | "manual"; - autopilotOwnerId?: string; - defaultExecutorKind?: OrchestratorExecutorKind; - defaultRetryLimit?: number; - metadata?: Record<string, unknown> | null; - plannerProvider?: OrchestratorPlannerProvider; -}; - -export type MissionRunStartResult = { - started: ReturnType<ReturnType<typeof createOrchestratorService>["startRunFromMission"]> | null; - mission: MissionDetail | null; -}; - -export type OrchestratorHookEvent = "TeammateIdle" | "TaskCompleted"; - -export type ResolvedOrchestratorHook = { - command: string; - timeoutMs: number; -}; - -export type ResolvedOrchestratorHooks = Partial<Record<OrchestratorHookEvent, ResolvedOrchestratorHook>>; - -export type OrchestratorHookExecutionResult = { - exitCode: number | null; - signal: string | null; - timedOut: boolean; - durationMs: number; - stdout: string; - stderr: string; - spawnError: string | null; -}; - -export type OrchestratorHookCommandRunner = (args: { - command: string; - cwd: string; - timeoutMs: number; - env: Record<string, string>; -}) => Promise<OrchestratorHookExecutionResult>; - -export type ResolvedOrchestratorConfig = { - /** Resolved model ID for the orchestrator (from defaultOrchestratorModel). */ - defaultOrchestratorModelId: string | null; - defaultExecutionPolicy: Partial<MissionExecutionPolicy> | null; - defaultMissionLevelSettings: MissionLevelSettings | null; - hooks: ResolvedOrchestratorHooks; -}; - -export type RuntimeReasoningEffort = "low" | "medium" | "high"; - -export type MissionRuntimeProfile = { - planning: { - useAiPlanner: boolean; - preferProvider: string | null; - }; - execution: { - maxParallelWorkers: number; - defaultRetryLimit: number; - stepTimeoutMs: number; - }; - evaluation: { - evaluateEveryStep: boolean; - autoAdjustPlan: boolean; - autoResolveInterventions: boolean; - interventionConfidenceThreshold: number; - evaluationReasoningEffort: RuntimeReasoningEffort; - interventionReasoningEffort: RuntimeReasoningEffort; - }; - context: { - contextProfile: string; - }; - provenance: { - source: "policy"; - }; -}; - -export type SessionRuntimeSignal = { - laneId: string | null; - sessionId: string; - runtimeState: TerminalRuntimeState; - lastOutputPreview: string | null; - at: string; -}; - -export type AttemptRuntimeTracker = { - lastPreviewDigest: string | null; - digestSinceMs: number; - repeatCount: number; - lastWaitingInterventionAtMs: number; - lastEventHeartbeatAtMs: number; - lastWaitingNotifiedAtMs: number; - lastQuestionThreadId: string | null; - lastQuestionMessageId: string | null; - lastPersistedAtMs: number; -}; - -export type OrchestratorChatSessionState = { - provider: "claude" | "codex"; - sessionId: string; - updatedAt: string; -}; - -export type AgentChatSessionSummaryEntry = Awaited< - ReturnType<ReturnType<typeof createAgentChatService>["listSessions"]> ->[number]; - -export type Deferred<T> = { - promise: Promise<T>; - resolve: (value: T) => void; - reject: (error: unknown) => void; - settled: boolean; -}; - -export type WorkerDeliveryContext = { - missionId: string; - threadId: string; - target: Extract<OrchestratorChatTarget, { kind: "worker" }>; - runId: string | null; - stepId: string | null; - stepKey: string | null; - attemptId: string | null; - laneId: string | null; - sessionId: string | null; - sessionStatus: string | null; - sessionToolType: string | null; - executorKind: OrchestratorExecutorKind | null; -}; - -export type WorkerDeliverySessionResolution = { - sessionId: string | null; - source: "sticky" | "mapped" | "lane_fallback"; - providerHint: "codex" | "claude" | null; - summary: AgentChatSessionSummaryEntry | null; - error: string | null; -}; - -export type ResolvedCallTypeConfig = { - provider: "claude" | "codex"; - model: string; - reasoningEffort: string; -}; - -export type CoordinatorSessionEntry = { - sessionId: string | null; - missionId: string; - runId: string; - modelConfig: ModelConfig; - startedAt: string; - eventCount: number; - lastEventAt: string | null; - dead: boolean; - startupGreetingSent: boolean; - systemPrompt: string; - pendingInit: Promise<void> | null; -}; - -export type ParallelMissionStepDescriptor = { - id: string; - index: number; - title: string; - kind: string; - laneId: string | null; - stepType: string; - stepKey: string; - dependencyStepKeys: string[]; -}; - -// ── Constants ─────────────────────────────────────────────────────── - -export const STEERING_DIRECTIVES_METADATA_KEY = "steeringDirectives"; -export const ORCHESTRATOR_CHAT_METADATA_KEY = "orchestratorChat"; -export const ORCHESTRATOR_CHAT_SESSION_METADATA_KEY = "orchestratorChatSession"; -export const MAX_PERSISTED_STEERING_DIRECTIVES = 200; -export const MAX_PERSISTED_CHAT_MESSAGES = 200; -export const HEALTH_SWEEP_INTERVAL_MS = 30_000; -export const HEALTH_SWEEP_ACTIVE_RUN_SCAN_LIMIT = 200; -export const STALE_ATTEMPT_GRACE_MS = 10_000; -export const WORKER_WAITING_INPUT_INTERVENTION_COOLDOWN_MS = 120_000; -export const WORKER_EVENT_HEARTBEAT_INTERVAL_MS = 10_000; -export const MAX_CHAT_CONTEXT_CHARS = 4_000; -export const MAX_CHAT_CONTEXT_MESSAGES = 8; -export const MAX_CHAT_LINE_CHARS = 420; -export const MAX_LATEST_CHAT_MESSAGE_CHARS = 1_500; -export const SESSION_SIGNAL_RETENTION_MS = 5 * 60_000; -export const GRACEFUL_CANCEL_NOTIFY_TIMEOUT_MS = 1_200; -export const GRACEFUL_CANCEL_INTERRUPT_TIMEOUT_MS = 1_500; -export const GRACEFUL_CANCEL_DISPOSE_TIMEOUT_MS = 2_500; -export const GRACEFUL_CANCEL_DRAIN_WAIT_MS = 2_000; -export const GRACEFUL_CANCEL_DRAIN_POLL_MS = 100; -export const MAX_STEERING_CONTEXT_DIRECTIVES = 8; -export const MAX_STEERING_CONTEXT_CHARS = 1_600; -export const MAX_STEERING_DIRECTIVES_PER_STEP = 24; -export const ATTEMPT_RUNTIME_PERSIST_INTERVAL_MS = 2_000; -export const MAX_RUNTIME_SIGNAL_PREVIEW_CHARS = 320; -export const GATE_PHASE_STEP_TYPES = new Set(["test", "validation", "review", "code_review", "test_review", "integration"]); -export const QUALITY_GATE_MAX_OUTPUT_CHARS = 4_000; -export const DEFAULT_METRIC_TOGGLES: MissionMetricToggle[] = [ - "planning", - "implementation", - "testing", - "validation", - "code_review", - "test_review", - "integration", - "cost", - "tokens", - "retries", - "claims", - "context_pressure", - "interventions" -]; -export const KNOWN_METRIC_TOGGLES = new Set<MissionMetricToggle>(DEFAULT_METRIC_TOGGLES); -export const DEFAULT_CHAT_VISIBILITY: OrchestratorChatVisibilityMode = "full"; -export const DEFAULT_CHAT_DELIVERY: OrchestratorChatDeliveryState = "delivered"; -export const DEFAULT_WORKER_CHAT_VISIBILITY: OrchestratorChatVisibilityMode = "digest_only"; -export const DEFAULT_THREAD_STATUS = "active"; -export const DEFAULT_CHAT_THREAD_TITLE = "Mission Coordinator"; -export const MAX_THREAD_PAGE_SIZE = 200; -export const CONTEXT_CHECKPOINT_CHAT_THRESHOLD = 120; -export const WORKER_MESSAGE_RETRY_BUDGET = 4; -export const WORKER_MESSAGE_RETRY_BACKOFF_BASE_MS = 5_000; -export const WORKER_MESSAGE_RETRY_BACKOFF_MAX_MS = 90_000; -export const WORKER_MESSAGE_RETRY_INTERVENTION_COOLDOWN_MS = 90_000; -export const WORKER_MESSAGE_INFLIGHT_LEASE_MS = 45_000; -export const WORKER_MESSAGE_INFLIGHT_STALE_FAIL_MS = 180_000; -export const ACTIVE_ATTEMPT_STATUSES = new Set(["queued", "running", "blocked"]); -export const ORCHESTRATOR_HOOK_DEFAULT_TIMEOUT_MS = 10_000; -export const ORCHESTRATOR_HOOK_MAX_TIMEOUT_MS = 300_000; -export const ORCHESTRATOR_HOOK_MAX_CAPTURE_CHARS = 8_000; -export const ORCHESTRATOR_HOOK_LOG_PREVIEW_CHARS = 480; -export const DECISION_TIMEOUT_CAP_MS_BY_HOURS: Record<number, number> = { - 6: 6 * 60 * 60 * 1_000, - 12: 12 * 60 * 60 * 1_000, - 24: 24 * 60 * 60 * 1_000, - 48: 48 * 60 * 60 * 1_000 -}; -export const TERMINAL_STEP_STATUSES = new Set<OrchestratorStepStatus>(["succeeded", "failed", "skipped", "superseded", "canceled"]); -export const CALL_TYPE_DEFAULTS: Record<OrchestratorCallType, ResolvedCallTypeConfig> = { - coordinator: { provider: "claude", model: "anthropic/claude-sonnet-4-6", reasoningEffort: "high" }, - chat_response: { provider: "claude", model: "anthropic/claude-sonnet-4-6", reasoningEffort: "medium" }, -}; - -// ── OrchestratorContext ────────────────────────────────────────────── - -export type OrchestratorContext = { - // Dependencies - db: AdeDb; - logger: Logger; - missionService: ReturnType<typeof createMissionService>; - orchestratorService: ReturnType<typeof createOrchestratorService>; - agentChatService: ReturnType<typeof createAgentChatService> | null | undefined; - laneService: ReturnType<typeof createLaneService> | null | undefined; - projectConfigService: ReturnType<typeof createProjectConfigService> | null | undefined; - aiIntegrationService: ReturnType<typeof createAiIntegrationService> | null | undefined; - prService: ReturnType<typeof createPrService> | null | undefined; - missionBudgetService: import("./missionBudgetService").MissionBudgetService | null | undefined; - computerUseArtifactBrokerService: ComputerUseArtifactBrokerService | null | undefined; - projectRoot: string | undefined; - onThreadEvent: ((event: OrchestratorThreadEvent) => void) | undefined; - onDagMutation: ((event: DagMutationEvent) => void) | undefined; - hookCommandRunner: OrchestratorHookCommandRunner; - - // Mutable state maps - syncLocks: Set<string>; - workerStates: Map<string, OrchestratorWorkerState>; - activeSteeringDirectives: Map<string, UserSteeringDirective[]>; - runRuntimeProfiles: Map<string, MissionRuntimeProfile>; - chatMessages: Map<string, OrchestratorChatMessage[]>; - activeChatSessions: Map<string, OrchestratorChatSessionState>; - chatTurnQueues: Map<string, Promise<void>>; - activeHealthSweepRuns: Set<string>; - sessionRuntimeSignals: Map<string, SessionRuntimeSignal>; - attemptRuntimeTrackers: Map<string, AttemptRuntimeTracker>; - sessionSignalQueues: Map<string, Promise<void>>; - workerDeliveryThreadQueues: Map<string, Promise<void>>; - workerDeliveryInterventionCooldowns: Map<string, number>; - runTeamManifests: Map<string, TeamManifest>; - runRecoveryLoopStates: Map<string, RecoveryLoopState>; - cancelingRuns: Set<string>; - aiTimeoutBudgetStepLocks: Set<string>; - aiTimeoutBudgetRunLocks: Set<string>; - aiRetryDecisionLocks: Set<string>; - coordinatorSessions: Map<string, CoordinatorSessionEntry>; - coordinatorThinkingLoops: Map<string, NodeJS.Timeout>; - pendingCoordinatorEvals: Map<string, NodeJS.Timeout>; - - // Coordinator and team runtime state - coordinatorAgents: Map<string, import("./coordinatorAgent").CoordinatorAgent>; - coordinatorRecoveryAttempts: Map<string, number>; - teamRuntimeStates: Map<string, OrchestratorTeamRuntimeState>; - callTypeConfigCache: Map<string, { config: ResolvedCallTypeConfig; expiresAt: number }>; - - // Scalar mutable state (wrapped for mutation through context) - disposed: { current: boolean }; - healthSweepTimer: { current: NodeJS.Timeout | null }; -}; - -// ── Utility Functions ─────────────────────────────────────────────── - -// Re-exported from ../shared/utils (imported at top of file) -export { nowIso }; - -export function createDeferred<T>(): Deferred<T> { - let settle: ((value: T) => void) | null = null; - let rejectFn: ((error: unknown) => void) | null = null; - const deferred: Deferred<T> = { - promise: new Promise<T>((resolve, reject) => { - settle = resolve; - rejectFn = reject; - }), - resolve(value: T) { - if (deferred.settled) return; - deferred.settled = true; - settle?.(value); - }, - reject(error: unknown) { - if (deferred.settled) return; - deferred.settled = true; - rejectFn?.(error); - }, - settled: false - }; - return deferred; -} - -export async function runBestEffortWithTimeout(args: { - timeoutMs: number; - work: () => Promise<unknown>; -}): Promise<{ ok: boolean; timedOut: boolean; error: string | null }> { - let timer: NodeJS.Timeout | null = null; - try { - const outcome = await Promise.race([ - Promise.resolve() - .then(() => args.work()) - .then(() => ({ kind: "ok" as const })) - .catch((error) => ({ kind: "error" as const, error })), - new Promise<{ kind: "timeout" }>((resolve) => { - timer = setTimeout(() => resolve({ kind: "timeout" }), Math.max(1, Math.floor(args.timeoutMs))); - }) - ]); - if (outcome.kind === "ok") { - return { ok: true, timedOut: false, error: null }; - } - if (outcome.kind === "timeout") { - return { ok: false, timedOut: true, error: "timed_out" }; - } - return { - ok: false, - timedOut: false, - error: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) - }; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -export function isRecord(value: unknown): value is Record<string, unknown> { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -export function asRecord(value: unknown): Record<string, unknown> | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record<string, unknown>; -} - -export function isDisplayOnlyTaskStep( - step: { metadata?: unknown } | null | undefined, -): boolean { - const metadata = asRecord(step?.metadata); - if (!metadata) return false; - if (metadata.isTask === true || metadata.displayOnlyTask === true) return true; - const stepType = typeof metadata.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - if (stepType !== "task") return false; - return metadata.spawnedByCoordinator !== true && typeof metadata.workerName !== "string"; -} - -export function filterExecutionSteps<T extends { metadata?: unknown }>(steps: T[]): T[] { - return steps.filter((step) => !isDisplayOnlyTaskStep(step)); -} - -export function asBool(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - -export function asString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - -export function parseSteeringDirective(value: unknown, missionId: string): UserSteeringDirective | null { - if (!isRecord(value)) return null; - const directive = typeof value.directive === "string" ? value.directive.trim() : ""; - if (!directive.length) return null; - const priority = value.priority === "instruction" || value.priority === "override" ? value.priority : "suggestion"; - return { - missionId, - directive, - priority, - targetStepKey: typeof value.targetStepKey === "string" ? value.targetStepKey : null - }; -} - -export function parseChatVisibility(value: unknown): OrchestratorChatVisibilityMode | null { - if (value === "full" || value === "digest_only" || value === "metadata_only") return value; - return null; -} - -export function parseChatDeliveryState(value: unknown): OrchestratorChatDeliveryState | null { - if (value === "queued" || value === "delivered" || value === "failed") return value; - return null; -} - -export function parseChatTarget(value: unknown): OrchestratorChatTarget | null { - if (!isRecord(value)) return null; - const kind = - value.kind === "coordinator" - || value.kind === "teammate" - || value.kind === "worker" - || value.kind === "workers" - ? value.kind - : null; - if (!kind) return null; - if (kind === "coordinator") { - return { - kind: "coordinator", - runId: typeof value.runId === "string" ? value.runId : null, - sessionId: typeof value.sessionId === "string" ? value.sessionId : null, - laneId: typeof value.laneId === "string" ? value.laneId : null - }; - } - if (kind === "teammate") { - return { - kind: "teammate", - runId: typeof value.runId === "string" ? value.runId : null, - teamMemberId: typeof value.teamMemberId === "string" ? value.teamMemberId : null, - sessionId: typeof value.sessionId === "string" ? value.sessionId : null - }; - } - if (kind === "workers") { - return { - kind: "workers", - runId: typeof value.runId === "string" ? value.runId : null, - laneId: typeof value.laneId === "string" ? value.laneId : null, - includeClosed: value.includeClosed === true - }; - } - return { - kind: "worker", - runId: typeof value.runId === "string" ? value.runId : null, - stepId: typeof value.stepId === "string" ? value.stepId : null, - stepKey: typeof value.stepKey === "string" ? value.stepKey : null, - attemptId: typeof value.attemptId === "string" ? value.attemptId : null, - sessionId: typeof value.sessionId === "string" ? value.sessionId : null, - laneId: typeof value.laneId === "string" ? value.laneId : null - }; -} - -export function parseThreadType(value: unknown): OrchestratorChatThreadType | null { - if (value === "coordinator" || value === "teammate" || value === "worker") return value; - return null; -} - -export function parseChatMessage(value: unknown, missionId: string): OrchestratorChatMessage | null { - if (!isRecord(value)) return null; - const role = value.role === "user" || value.role === "orchestrator" || value.role === "worker" - ? value.role - : null; - if (!role) return null; - const content = typeof value.content === "string" ? value.content.trim() : ""; - const timestamp = typeof value.timestamp === "string" ? value.timestamp : nowIso(); - if (!content.length) return null; - const id = typeof value.id === "string" ? value.id.trim() : ""; - if (!id.length) return null; - return { - id, - missionId, - role, - content, - timestamp, - stepKey: typeof value.stepKey === "string" ? value.stepKey : null, - threadId: typeof value.threadId === "string" ? value.threadId : undefined, - target: parseChatTarget(value.target), - visibility: parseChatVisibility(value.visibility) ?? DEFAULT_CHAT_VISIBILITY, - deliveryState: parseChatDeliveryState(value.deliveryState) ?? DEFAULT_CHAT_DELIVERY, - sourceSessionId: typeof value.sourceSessionId === "string" ? value.sourceSessionId : undefined, - attemptId: typeof value.attemptId === "string" ? value.attemptId : undefined, - laneId: typeof value.laneId === "string" ? value.laneId : undefined, - runId: typeof value.runId === "string" ? value.runId : undefined, - metadata: isRecord(value.metadata) ? value.metadata : null - }; -} - -export function parseChatSessionState(value: unknown): OrchestratorChatSessionState | null { - if (!isRecord(value)) return null; - const provider = value.provider === "claude" || value.provider === "codex" ? value.provider : null; - const sessionId = typeof value.sessionId === "string" ? value.sessionId.trim() : ""; - if (!provider || !sessionId.length) return null; - return { - provider, - sessionId, - updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : nowIso() - }; -} - -export function normalizeSignalText(value: string | null | undefined): string { - if (!value || typeof value !== "string") return ""; - return value - .replace(/\u0000/g, "") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); -} - -export function digestSignalText(value: string | null | undefined): string | null { - const normalized = normalizeSignalText(value); - if (!normalized.length) return null; - return createHash("sha256").update(normalized).digest("hex").slice(0, 16); -} - -export function buildQuestionThreadLink(args: { - attemptId: string; - preview: string | null; - occurredAt: string; -}): OrchestratorRuntimeQuestionLink { - const attemptId = String(args.attemptId ?? "").trim(); - const threadId = `question:${attemptId}`; - const digest = digestSignalText(args.preview) ?? "none"; - const secondBucket = Math.max(0, Math.floor(Date.parse(args.occurredAt) / 1000) || 0); - return { - threadId, - messageId: `question:${attemptId}:${digest}:${secondBucket}`, - replyTo: null - }; -} - -export function buildQuestionReplyLink(args: { - threadId: string; - replyTo: string; - interventionId: string; - directive: string; -}): OrchestratorRuntimeQuestionLink { - const digest = digestSignalText(args.directive) ?? "none"; - return { - threadId: args.threadId, - messageId: `question_reply:${args.interventionId}:${digest}`, - replyTo: args.replyTo - }; -} - -export function parseQuestionLink(value: unknown): OrchestratorRuntimeQuestionLink | null { - if (!isRecord(value)) return null; - const threadId = typeof value.threadId === "string" ? value.threadId.trim() : ""; - const messageId = typeof value.messageId === "string" ? value.messageId.trim() : ""; - const replyToRaw = typeof value.replyTo === "string" ? value.replyTo.trim() : ""; - if (!threadId.length || !messageId.length) return null; - return { - threadId, - messageId, - replyTo: replyToRaw.length > 0 ? replyToRaw : null - }; -} - -export function detectWaitingInputSignal(text: string | null | undefined): boolean { - const normalized = normalizeSignalText(text); - if (!normalized.length) return false; - return /waiting.{0,20}input|need.{0,20}(input|direction|guidance)/.test(normalized); -} - -export function clipTextForContext(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const suffix = "... (truncated)"; - return value.slice(0, Math.max(0, maxChars - suffix.length)) + suffix; -} - -export function workerStateFromRuntimeSignal(args: { - runtimeState: TerminalRuntimeState; - waitingForInput?: boolean; -}): OrchestratorWorkerStatus { - if (args.waitingForInput) return "waiting_input"; - switch (args.runtimeState) { - case "running": return "working"; - case "idle": return "idle"; - case "waiting-input": return "waiting_input"; - case "exited": return "completed"; - case "killed": return "failed"; - default: return "working"; - } -} - -export function parseTerminalRuntimeState(raw: unknown): TerminalRuntimeState | null { - const value = typeof raw === "string" ? raw.trim() : ""; - if (value === "running" || value === "idle" || value === "waiting-input" || value === "exited" || value === "killed") { - return value as TerminalRuntimeState; - } - return null; -} - -export function clipHookLogText(value: string | null | undefined): string | null { - const normalized = String(value ?? "").replace(/\u0000/g, "").trim(); - if (!normalized.length) return null; - return normalized.length > ORCHESTRATOR_HOOK_LOG_PREVIEW_CHARS - ? normalized.slice(0, ORCHESTRATOR_HOOK_LOG_PREVIEW_CHARS) + "..." - : normalized; -} - -export function parseOrchestratorHookConfig(raw: unknown): ResolvedOrchestratorHook | null { - if (!isRecord(raw)) return null; - const command = String(raw.command ?? "").trim(); - if (!command.length) return null; - const timeoutRaw = Number(raw.timeoutMs); - const timeoutMs = Number.isFinite(timeoutRaw) - ? Math.max(1_000, Math.min(ORCHESTRATOR_HOOK_MAX_TIMEOUT_MS, Math.floor(timeoutRaw))) - : ORCHESTRATOR_HOOK_DEFAULT_TIMEOUT_MS; - return { command, timeoutMs }; -} - -export function readOrchestratorHooksConfig(orchestrator: Record<string, unknown>): ResolvedOrchestratorHooks { - const hooksRaw = isRecord(orchestrator.hooks) ? orchestrator.hooks : null; - if (!hooksRaw) return {}; - const hooks: ResolvedOrchestratorHooks = {}; - const teammateIdle = parseOrchestratorHookConfig(hooksRaw.TeammateIdle ?? hooksRaw.teammateIdle ?? hooksRaw.teammate_idle); - if (teammateIdle) hooks.TeammateIdle = teammateIdle; - const taskCompleted = parseOrchestratorHookConfig(hooksRaw.TaskCompleted ?? hooksRaw.taskCompleted ?? hooksRaw.task_completed); - if (taskCompleted) hooks.TaskCompleted = taskCompleted; - return hooks; -} - -export function parseJsonRecord(raw: string | null | undefined): Record<string, unknown> | null { - if (!raw || typeof raw !== "string") return null; - try { - const parsed = JSON.parse(raw); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -} - -export function parseJsonArray(raw: string | null | undefined): unknown[] { - if (!raw || typeof raw !== "string") return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function missionThreadId(missionId: string): string { - return `mission:${missionId}`; -} - -export function clampLimit(rawLimit: number | null | undefined, fallback: number, max = MAX_THREAD_PAGE_SIZE): number { - const numeric = Number(rawLimit); - if (!Number.isFinite(numeric) || numeric < 1) return fallback; - return Math.min(max, Math.max(1, Math.floor(numeric))); -} - -export function normalizeChatVisibility(value: unknown, fallback: OrchestratorChatVisibilityMode = DEFAULT_CHAT_VISIBILITY): OrchestratorChatVisibilityMode { - return parseChatVisibility(value) ?? fallback; -} - -export function normalizeChatDeliveryState(value: unknown, fallback: OrchestratorChatDeliveryState = DEFAULT_CHAT_DELIVERY): OrchestratorChatDeliveryState { - return parseChatDeliveryState(value) ?? fallback; -} - -export function normalizeThreadType(value: unknown, fallback: OrchestratorChatThreadType = "coordinator"): OrchestratorChatThreadType { - return parseThreadType(value) ?? fallback; -} - -export function toOptionalString(value: unknown): string | null { - const raw = typeof value === "string" ? value.trim() : ""; - return raw.length > 0 ? raw : null; -} - -export function sanitizeChatTarget(target: OrchestratorChatTarget | null | undefined): OrchestratorChatTarget | null { - if (!target) return null; - if (target.kind === "coordinator") { - return { - kind: "coordinator", - runId: toOptionalString(target.runId), - sessionId: toOptionalString(target.sessionId), - laneId: toOptionalString(target.laneId) - }; - } - if (target.kind === "teammate") { - return { - kind: "teammate", - runId: toOptionalString(target.runId), - teamMemberId: toOptionalString(target.teamMemberId), - sessionId: toOptionalString(target.sessionId) - }; - } - if (target.kind === "workers") { - return { - kind: "workers", - runId: toOptionalString(target.runId), - laneId: toOptionalString(target.laneId), - includeClosed: target.includeClosed === true - }; - } - if (target.kind !== "worker") return null; - const sanitized: OrchestratorChatTarget = { - kind: "worker", - runId: toOptionalString(target.runId), - stepId: toOptionalString(target.stepId), - stepKey: toOptionalString(target.stepKey), - attemptId: toOptionalString(target.attemptId), - sessionId: toOptionalString(target.sessionId), - laneId: toOptionalString(target.laneId) - }; - return sanitized; -} - -export function parseWorkerProviderHint(raw: unknown): "codex" | "claude" | null { - const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; - if (value === "codex" || value === "openai") return "codex"; - if (value === "claude" || value === "anthropic") return "claude"; - return null; -} - -export function workerThreadIdentity(target: OrchestratorChatTarget | null | undefined): string | null { - if (!target || target.kind !== "worker") return null; - const options = [target.attemptId, target.sessionId, target.stepId, target.stepKey, target.laneId]; - for (const value of options) { - const normalized = toOptionalString(value); - if (normalized) return normalized; - } - return null; -} - -export function teammateThreadIdentity(target: OrchestratorChatTarget | null | undefined): string | null { - if (!target || target.kind !== "teammate") return null; - const options = [target.teamMemberId, target.sessionId, target.runId]; - for (const value of options) { - const normalized = toOptionalString(value); - if (normalized) return normalized; - } - return null; -} - -export function deriveThreadTitle(args: { - target: OrchestratorChatTarget | null | undefined; - step: { stepKey: string; title: string } | null | undefined; - lane: { name: string } | null | undefined; - fallback?: string; -}): string { - const teammate = args.target && args.target.kind === "teammate" ? args.target : null; - if (teammate) { - const teammateLabel = toOptionalString(teammate.teamMemberId) ?? toOptionalString(teammate.sessionId); - return teammateLabel ? `Teammate: ${teammateLabel}` : "Teammate"; - } - const target = args.target && args.target.kind === "worker" ? args.target : null; - const stepLabel = args.step?.title || args.step?.stepKey || target?.stepKey || null; - const suffix = - stepLabel ?? - (args.lane?.name ? `Lane: ${args.lane.name}` : null) ?? - target?.laneId ?? - null; - return suffix ? `Worker: ${suffix}` : (args.fallback ?? DEFAULT_CHAT_THREAD_TITLE); -} - -export function readConfig(projectConfigService: ReturnType<typeof createProjectConfigService> | null | undefined): ResolvedOrchestratorConfig { - const snapshot = projectConfigService?.get(); - const ai = snapshot?.effective?.ai; - const orchestrator = isRecord(ai) && isRecord(ai.orchestrator) ? (ai.orchestrator as Record<string, unknown>) : {}; - // Prefer defaultOrchestratorModel.modelId; fall back to legacy defaultPlannerProvider. - const orcModel = isRecord(orchestrator.defaultOrchestratorModel) ? orchestrator.defaultOrchestratorModel : null; - const orcModelId = orcModel && typeof orcModel.modelId === "string" ? orcModel.modelId.trim() : null; - const legacyProvider = asString(orchestrator.defaultPlannerProvider)?.trim() || null; - const defaultOrchestratorModelId = orcModelId || legacyProvider; - const defaultExecutionPolicy = isRecord(orchestrator.defaultExecutionPolicy) - ? (orchestrator.defaultExecutionPolicy as Partial<MissionExecutionPolicy>) - : null; - const defaultMissionLevelSettings = isRecord(orchestrator.defaultMissionLevelSettings) - ? (orchestrator.defaultMissionLevelSettings as MissionLevelSettings) - : null; - const hooks = readOrchestratorHooksConfig(orchestrator); - return { - defaultOrchestratorModelId, - defaultExecutionPolicy, - defaultMissionLevelSettings, - hooks - }; -} - -export function mapOrchestratorStepStatus(status: OrchestratorStepStatus): MissionStepStatus { - switch (status) { - case "pending": return "pending"; - case "ready": return "pending"; - case "running": return "running"; - case "succeeded": return "succeeded"; - case "failed": return "failed"; - case "skipped": return "skipped"; - case "superseded": return "skipped"; - case "blocked": return "blocked"; - case "canceled": return "canceled"; - default: return "pending"; - } -} - -export function deriveMissionStatusFromRun(graph: OrchestratorRunGraph, mission: MissionDetail): MissionStatus { - if (graph.run.status === "succeeded") { - return "completed"; - } - if (graph.run.status === "canceled") return "canceled"; - - const hasBlockingIntervention = mission.interventions.some((entry) => { - if (entry.status !== "open") return false; - if (entry.interventionType !== "manual_input") return true; - const metadata = isRecord(entry.metadata) ? entry.metadata : null; - return metadata?.canProceedWithoutAnswer !== true; - }); - if (hasBlockingIntervention) return "intervention_required"; - if (graph.run.status === "failed") return "failed"; - if ( - graph.run.status === "active" - || graph.run.status === "bootstrapping" - || graph.run.status === "queued" - || graph.run.status === "completing" - || graph.run.status === "paused" - ) { - return "in_progress"; - } - return mission.status; -} - -export function buildOutcomeSummary(graph: OrchestratorRunGraph): string { - const total = graph.steps.length; - const succeeded = graph.steps.filter((step) => step.status === "succeeded").length; - const superseded = graph.steps.filter((step) => step.status === "superseded").length; - const failed = graph.steps.filter((step) => step.status === "failed").length; - const blocked = graph.steps.filter((step) => step.status === "blocked").length; - const done = succeeded + superseded; - const remaining = total - done; - const attempts = graph.attempts.length; - const parts = [ - `${done}/${total} done`, - remaining > 0 ? `${remaining} remaining` : null, - superseded > 0 ? `${superseded} superseded` : null, - failed > 0 ? `${failed} failed` : null, - blocked > 0 ? `${blocked} blocked` : null, - `${attempts} total attempts` - ].filter(Boolean); - return parts.join(", ") + "."; -} - -export function buildConflictResolutionInstructions(conflictFiles: string[], sourceLaneName: string): string { - const fileList = conflictFiles.slice(0, 10); - const more = conflictFiles.length > 10 ? `\n ... and ${conflictFiles.length - 10} more files` : ""; - return [ - `# Conflict Resolution Instructions`, - ``, - `You are resolving merge conflicts in branch "${sourceLaneName}" that arose while merging into the integration branch.`, - ``, - `## Conflicting Files`, - fileList.map((f) => ` - ${f}`).join("\n") + more, - ``, - `## Steps`, - `1. For each file with conflict markers, resolve the conflict to produce correct code.`, - `2. Prefer the feature branch changes when they implement new functionality.`, - `3. Keep integration branch changes that fix bugs or update shared infrastructure.`, - `4. Run tests after resolving to ensure nothing is broken.`, - `5. Stage resolved files with \`git add\`.`, - `6. Do NOT commit or push — the orchestrator handles that.`, - ``, - `## Important`, - `- Do NOT run \`git merge\`, \`git rebase\`, \`git push\`, or \`git commit\`.`, - `- Focus only on editing the conflicting files to remove conflict markers.`, - `- Ensure the code compiles and tests pass after resolution.` - ].join("\n"); -} - -export function normalizeReasoningEffort(value: unknown, fallback: RuntimeReasoningEffort): RuntimeReasoningEffort { - if (typeof value !== "string") return fallback; - const normalized = value.trim().toLowerCase(); - if (normalized === "low" || normalized === "medium" || normalized === "high") return normalized; - return fallback; -} - -export function extractRunFailureMessage(graph: OrchestratorRunGraph): string | null { - const latestFailure = [...graph.attempts] - .filter((a) => a.status === "failed" && a.errorMessage) - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; - return latestFailure?.errorMessage ?? null; -} - -// ── Hook Command Runner ────────────────────────────────────────── - -export const runOrchestratorHookCommand: OrchestratorHookCommandRunner = async (args) => { - const startedAt = Date.now(); - return await new Promise<OrchestratorHookExecutionResult>((resolve) => { - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - let killTimer: NodeJS.Timeout | null = null; - - const finish = (result: Partial<OrchestratorHookExecutionResult>) => { - if (settled) return; - settled = true; - if (killTimer) { - clearTimeout(killTimer); - killTimer = null; - } - resolve({ - exitCode: result.exitCode ?? null, - signal: result.signal ?? null, - timedOut, - durationMs: Date.now() - startedAt, - stdout, - stderr, - spawnError: result.spawnError ?? null - }); - }; - - let child: ReturnType<typeof spawn>; - try { - child = spawn(args.command, { - cwd: args.cwd, - env: args.env, - shell: true, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true - }); - } catch (error) { - finish({ - spawnError: error instanceof Error ? error.message : String(error) - }); - return; - } - - const appendOutput = (target: "stdout" | "stderr", chunk: unknown) => { - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? ""); - if (!text.length) return; - if (target === "stdout") { - stdout = (stdout + text).slice(-ORCHESTRATOR_HOOK_MAX_CAPTURE_CHARS); - } else { - stderr = (stderr + text).slice(-ORCHESTRATOR_HOOK_MAX_CAPTURE_CHARS); - } - }; - - if (args.timeoutMs > 0) { - killTimer = setTimeout(() => { - timedOut = true; - terminateProcessTree(child, "SIGTERM"); - setTimeout(() => { - if (child.exitCode == null && !child.killed) { - terminateProcessTree(child, "SIGKILL"); - } - }, 1_000).unref(); - }, args.timeoutMs); - } - - child.stdout?.on("data", (chunk) => appendOutput("stdout", chunk)); - child.stderr?.on("data", (chunk) => appendOutput("stderr", chunk)); - child.on("error", (error) => { - finish({ - spawnError: error instanceof Error ? error.message : String(error) - }); - }); - child.on("close", (exitCode, signal) => { - finish({ - exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null, - signal: typeof signal === "string" ? signal : null - }); - }); - }); -}; - -// ── Runtime Profile Derivation ─────────────────────────────────── - -export function deriveRuntimeProfileFromPolicy( - policy: MissionExecutionPolicy, - config: ResolvedOrchestratorConfig -): MissionRuntimeProfile { - const testingEnabled = policy.testing.mode !== "none"; - const reviewEnabled = policy.codeReview.mode !== "off" || policy.testReview.mode !== "off"; - const strictGates = - policy.validation.mode === "required" - || policy.codeReview.mode === "required" - || policy.testReview.mode === "required"; - - let maxParallelWorkers = 2; - if (testingEnabled) maxParallelWorkers += 1; - if (reviewEnabled) maxParallelWorkers += 1; - if (policy.teamRuntime?.enabled) maxParallelWorkers += 2; - if (policy.testing.mode === "tdd") maxParallelWorkers = Math.max(maxParallelWorkers, 4); - maxParallelWorkers = Math.max(1, Math.min(6, maxParallelWorkers)); - - let stepTimeoutMs = 300_000; - if (!testingEnabled && !reviewEnabled) { - stepTimeoutMs = 180_000; - } - if (strictGates || policy.planning.mode === "manual_review") { - stepTimeoutMs = 600_000; - } else if (policy.testing.mode === "tdd") { - stepTimeoutMs = 480_000; - } - - return { - planning: { - useAiPlanner: policy.planning.mode !== "off", - preferProvider: policy.planning.model ?? config.defaultOrchestratorModelId ?? null - }, - execution: { - maxParallelWorkers, - defaultRetryLimit: strictGates || testingEnabled ? 2 : 1, - stepTimeoutMs - }, - evaluation: { - evaluateEveryStep: strictGates || policy.testing.mode === "tdd", - autoAdjustPlan: policy.planning.mode !== "off" || reviewEnabled, - autoResolveInterventions: testingEnabled || reviewEnabled, - interventionConfidenceThreshold: strictGates ? 0.75 : 0.9, - evaluationReasoningEffort: normalizeReasoningEffort( - policy.codeReview.reasoningEffort ?? policy.testReview.reasoningEffort ?? policy.validation.reasoningEffort, - strictGates ? "high" : "medium" - ), - interventionReasoningEffort: normalizeReasoningEffort( - policy.planning.reasoningEffort, - strictGates ? "high" : "medium" - ) - }, - context: { - contextProfile: "orchestrator_deterministic_v1", - }, - provenance: { - source: "policy" - } - }; -} - -// ── Phase-card-based Runtime Profile Derivation ────────────────── - -export function deriveRuntimeProfileFromPhases( - phases: PhaseCard[], - settings: MissionLevelSettings, - config: ResolvedOrchestratorConfig -): MissionRuntimeProfile { - // Build a set of enabled phase keys - const phaseKeys = new Set(phases.map((p) => p.phaseKey.toLowerCase())); - - const testingEnabled = phaseKeys.has("testing") || phaseKeys.has("test"); - const reviewEnabled = phaseKeys.has("code_review") || phaseKeys.has("codereview") || phaseKeys.has("review") - || phaseKeys.has("test_review") || phaseKeys.has("testreview"); - const hasStrictGates = phases.some( - (p) => p.validationGate.required && p.validationGate.tier !== "none" - ); - const hasTdd = phases.some( - (p) => (p.phaseKey.toLowerCase() === "testing" || p.phaseKey.toLowerCase() === "test") - && p.instructions.toLowerCase().includes("tdd") - ); - const hasManualReview = false; - - let maxParallelWorkers = 2; - if (testingEnabled) maxParallelWorkers += 1; - if (reviewEnabled) maxParallelWorkers += 1; - if (settings.teamRuntime?.enabled) maxParallelWorkers += 2; - if (hasTdd) maxParallelWorkers = Math.max(maxParallelWorkers, 4); - maxParallelWorkers = Math.max(1, Math.min(6, maxParallelWorkers)); - - let stepTimeoutMs = 300_000; - if (!testingEnabled && !reviewEnabled) { - stepTimeoutMs = 180_000; - } - if (hasStrictGates || hasManualReview) { - stepTimeoutMs = 600_000; - } else if (hasTdd) { - stepTimeoutMs = 480_000; - } - - // Derive reasoning effort from phase cards that have validation or review roles - const reviewPhases = phases.filter( - (p) => ["validation", "code_review", "codereview", "review", "test_review", "testreview"] - .includes(p.phaseKey.toLowerCase()) - ); - return { - planning: { - useAiPlanner: true, - preferProvider: config.defaultOrchestratorModelId ?? null - }, - execution: { - maxParallelWorkers, - defaultRetryLimit: hasStrictGates || testingEnabled ? 2 : 1, - stepTimeoutMs - }, - evaluation: { - evaluateEveryStep: hasStrictGates || hasTdd, - autoAdjustPlan: true, - autoResolveInterventions: testingEnabled || reviewEnabled, - interventionConfidenceThreshold: hasStrictGates ? 0.75 : 0.9, - evaluationReasoningEffort: normalizeReasoningEffort( - reviewPhases[0]?.model?.modelId, - hasStrictGates ? "high" : "medium" - ), - interventionReasoningEffort: normalizeReasoningEffort( - config.defaultOrchestratorModelId ?? null, - hasStrictGates ? "high" : "medium" - ) - }, - context: { - contextProfile: "orchestrator_deterministic_v1", - }, - provenance: { - source: "policy" - } - }; -} - -// ── Model Capabilities ─────────────────────────────────────────── - -export function getModelCapabilities(): GetModelCapabilitiesResult { - const profiles: ModelCapabilityProfile[] = [ - { - provider: "claude", - modelId: "claude-opus-4-7", - displayName: "Claude Opus 4.7", - strengths: ["complex reasoning", "architectural planning", "nuanced review", "deep analysis"], - weaknesses: ["high cost tier", "not recommended for bulk implementation"], - costTier: "very_high", - bestFor: ["planning", "review"], - parallelCapable: false, - reasoningTiers: ["low", "medium", "high"] - }, - { - provider: "claude", - modelId: "claude-sonnet-4-6", - displayName: "Claude Sonnet 4.6", - strengths: ["fast balanced quality", "code review", "planning", "narrative generation"], - weaknesses: ["very large scope implementation", "long-running autonomous tasks"], - costTier: "medium", - bestFor: ["review", "planning", "narrative"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high"] - }, - { - provider: "claude", - modelId: "claude-haiku-4-5", - displayName: "Claude Haiku 4.5", - strengths: ["fastest Claude variant", "narrative generation", "summaries", "quick classification"], - weaknesses: ["limited on complex multi-step reasoning", "not suited for large implementations"], - costTier: "low", - bestFor: ["narrative"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high"] - }, - { - provider: "codex", - modelId: "gpt-5.5", - displayName: "GPT-5.5", - strengths: ["frontier coding", "complex implementation", "testing", "code review"], - weaknesses: ["higher latency", "not needed for simple edits"], - costTier: "high", - bestFor: ["implementation", "review"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high", "xhigh"] - }, - { - provider: "codex", - modelId: "gpt-5.4", - displayName: "GPT-5.4", - strengths: ["strong implementation", "reliable coding tasks", "test writing"], - weaknesses: ["less capable than GPT-5.5 on complex tasks"], - costTier: "high", - bestFor: ["implementation"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high", "xhigh"] - }, - { - provider: "codex", - modelId: "gpt-5.4-mini", - displayName: "GPT-5.4-Mini", - strengths: ["fast coding", "quick edits", "lower-cost iteration"], - weaknesses: ["less capable than GPT-5.5 on complex multi-file refactors"], - costTier: "low", - bestFor: ["implementation"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high", "xhigh"] - }, - { - provider: "codex", - modelId: "gpt-5.3-codex", - displayName: "GPT-5.3 Codex", - strengths: ["agentic coding", "implementation", "testing", "code review"], - weaknesses: ["older than GPT-5.5 for general tasks"], - costTier: "high", - bestFor: ["implementation", "review"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high", "xhigh"] - }, - { - provider: "codex", - modelId: "o4-mini", - displayName: "o4-mini", - strengths: ["fast reasoning model", "analysis", "planning with speed"], - weaknesses: ["less capable than full o3 on deep analysis"], - costTier: "low", - bestFor: ["planning", "review"], - parallelCapable: true, - reasoningTiers: ["low", "medium", "high"] - }, - { - provider: "codex", - modelId: "o3", - displayName: "o3", - strengths: ["advanced reasoning", "complex analysis", "architecture", "deep debugging"], - weaknesses: ["higher cost", "slower than mini variants"], - costTier: "high", - bestFor: ["planning", "review"], - parallelCapable: false, - reasoningTiers: ["low", "medium", "high", "xhigh"] - } - ]; - return { profiles }; -} diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorPlanning.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorPlanning.test.ts deleted file mode 100644 index ac9b64800..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorPlanning.test.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DelegationContract, OrchestratorRunGraph, OrchestratorStep } from "../../../shared/types"; -import { - deriveMissionStatusFromRun, - filterExecutionSteps, - isDisplayOnlyTaskStep, - parseChatTarget, - sanitizeChatTarget, - teammateThreadIdentity, - deriveThreadTitle, -} from "./orchestratorContext"; -import { - checkCoordinatorToolPermission, - createDelegationContract, - createDelegationScope, - deriveCoordinatorCapabilities, - derivePlanningStartupStateFromContract, - extractActiveDelegationContracts, - hasConflictingDelegationContract, -} from "./delegationContracts"; - -describe("orchestratorContext teammate chat target handling", () => { - it("parses teammate targets from persisted JSON payloads", () => { - const parsed = parseChatTarget({ - kind: "teammate", - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }); - - expect(parsed).toEqual({ - kind: "teammate", - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }); - }); - - it("sanitizes teammate targets without dropping routing fields", () => { - const sanitized = sanitizeChatTarget({ - kind: "teammate", - runId: " run-1 ", - teamMemberId: " tm-1 ", - sessionId: " session-1 ", - }); - - expect(sanitized).toEqual({ - kind: "teammate", - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }); - }); - - it("builds teammate thread identity and title", () => { - const target = { - kind: "teammate" as const, - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }; - - expect(teammateThreadIdentity(target)).toBe("tm-1"); - expect(deriveThreadTitle({ target, step: null, lane: null })).toBe("Teammate: tm-1"); - }); -}); - -describe("deriveMissionStatusFromRun", () => { - it("keeps missions intervention_required while blocking manual input is open", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "active" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "in_progress", - interventions: [ - { - id: "iv-1", - missionId: "mission-1", - interventionType: "manual_input", - status: "open", - title: "Need answer", - body: "Question", - requestedAction: null, - resolutionNote: null, - laneId: null, - createdAt: "", - updatedAt: "", - resolvedAt: null, - metadata: { canProceedWithoutAnswer: false }, - }, - ], - } as any - ); - - expect(status).toBe("intervention_required"); - }); - - it("keeps active missions in progress when manual input is optional", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "active" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "in_progress", - interventions: [ - { - id: "iv-1", - missionId: "mission-1", - interventionType: "manual_input", - status: "open", - title: "Optional note", - body: "Question", - requestedAction: null, - resolutionNote: null, - laneId: null, - createdAt: "", - updatedAt: "", - resolvedAt: null, - metadata: { canProceedWithoutAnswer: true }, - }, - ], - } as any - ); - - expect(status).toBe("in_progress"); - }); - - it("maps canceled runs to canceled missions", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "canceled" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "in_progress", - interventions: [], - } as any - ); - - expect(status).toBe("canceled"); - }); - - it("keeps canceled runs terminal even when stale blocking interventions are open", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "canceled" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "intervention_required", - interventions: [ - { - id: "iv-1", - missionId: "mission-1", - interventionType: "failed_step", - status: "open", - title: "Worker failed", - body: "A worker failed before cancellation.", - requestedAction: null, - resolutionNote: null, - laneId: null, - createdAt: "", - updatedAt: "", - resolvedAt: null, - metadata: {}, - }, - ], - } as any - ); - - expect(status).toBe("canceled"); - }); -}); - -describe("task step helpers", () => { - it("filters legacy task shell steps without reviving display-only rendering", () => { - const steps = [ - { id: "task-1", stepKey: "plan", metadata: { isTask: true, stepType: "task" } }, - { id: "step-1", stepKey: "impl", metadata: { stepType: "implementation" } }, - ] as any[]; - - expect(isDisplayOnlyTaskStep(steps[0])).toBe(true); - expect(isDisplayOnlyTaskStep(steps[1])).toBe(false); - expect(filterExecutionSteps(steps).map((step) => step.stepKey)).toEqual(["impl"]); - }); - - it("filters legacy task rows that only have stepType task", () => { - const steps = [ - { id: "task-1", stepKey: "plan", metadata: { stepType: "task" } }, - { id: "step-1", stepKey: "impl", metadata: { stepType: "implementation" } }, - ] as any[]; - - expect(isDisplayOnlyTaskStep(steps[0])).toBe(true); - expect(filterExecutionSteps(steps).map((step) => step.stepKey)).toEqual(["impl"]); - }); -}); - -function createDelegationGraph(args?: { - steps?: OrchestratorStep[]; -}): OrchestratorRunGraph { - return { - run: { - id: "run-1", - metadata: {}, - }, - steps: args?.steps ?? [], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - } as unknown as OrchestratorRunGraph; -} - -function createPlannerContract(overrides?: Partial<DelegationContract>): DelegationContract { - return createDelegationContract({ - contractId: "contract-planner", - runId: "run-1", - workerIntent: "planner", - mode: "exclusive", - scope: createDelegationScope({ - kind: "phase", - key: "phase:planning", - label: "Planning", - }), - phaseKey: "planning", - ...(overrides ?? {}), - }); -} - -function createDelegationStep(args?: { - id?: string; - stepKey?: string; - status?: OrchestratorStep["status"]; - metadata?: Record<string, unknown>; -}): OrchestratorStep { - return { - id: args?.id ?? "step-1", - runId: "run-1", - missionStepId: null, - stepIndex: 0, - stepKey: args?.stepKey ?? "planner-worker", - title: "Planner", - detail: "Plan the work", - kind: "planning", - status: args?.status ?? "pending", - laneId: null, - joinPolicy: "all_success", - quorumCount: null, - approvalState: null, - dependsOnStepIds: [], - dependencyStepIds: [], - artifactIds: [], - claimIds: [], - metadata: args?.metadata ?? {}, - createdAt: "2026-03-13T00:00:00.000Z", - updatedAt: "2026-03-13T00:00:00.000Z", - startedAt: null, - completedAt: null, - } as unknown as OrchestratorStep; -} - -describe("delegationContracts", () => { - it("derives strict planner capabilities while the planner is active", () => { - expect( - deriveCoordinatorCapabilities({ - mode: "exclusive", - workerIntent: "planner", - status: "active", - launchState: "waiting_on_worker", - }), - ).toEqual(["observe", "run_control", "update_mission_state"]); - }); - - it("keeps broader coordination capabilities for bounded parallel delegation", () => { - expect( - deriveCoordinatorCapabilities({ - mode: "bounded_parallel", - workerIntent: "specialist", - status: "active", - }), - ).toEqual( - expect.arrayContaining([ - "observe", - "read_repo", - "spawn_top_level_worker", - "spawn_nested_worker", - "spawn_parallel_workers", - "message_workers", - ]), - ); - }); - - it("blocks coordinator repo exploration while an active planner contract owns planning", () => { - const contract = createPlannerContract({ - status: "active", - launchState: "waiting_on_worker", - }); - const permission = checkCoordinatorToolPermission({ - toolName: "read_file", - contracts: [contract], - }); - expect(permission).toMatchObject({ - allowed: false, - capability: "read_repo", - }); - }); - - it("allows startup-safe tools while planner launch is still in progress", () => { - const contract = createPlannerContract({ - status: "launching", - launchState: "fetching_context", - }); - expect( - checkCoordinatorToolPermission({ - toolName: "get_project_context", - contracts: [contract], - }), - ).toEqual({ allowed: true }); - expect( - checkCoordinatorToolPermission({ - toolName: "spawn_worker", - contracts: [contract], - }), - ).toEqual({ allowed: true }); - }); - - it("derives active planner contracts from worker step status", () => { - const contract = createPlannerContract({ - activeWorkerIds: ["planner-worker"], - status: "launching", - launchState: "awaiting_worker_launch", - }); - const graph = createDelegationGraph({ - steps: [ - createDelegationStep({ - id: "step-planner", - stepKey: "planner-worker", - status: "running", - metadata: { - delegationContract: contract, - }, - }), - ], - }); - - expect(extractActiveDelegationContracts(graph)).toEqual([ - expect.objectContaining({ - contractId: "contract-planner", - status: "active", - launchState: "waiting_on_worker", - }), - ]); - }); - - it("treats planner startup failure contracts as failed planning state", () => { - const contract = createPlannerContract({ - status: "blocked", - launchState: "blocked", - }); - expect(derivePlanningStartupStateFromContract(contract)).toEqual({ - state: "failed", - contract, - }); - }); - - it("flags conflicting exclusive delegation scopes and allows non-exclusive overlap", () => { - const existing = createPlannerContract({ - contractId: "contract-existing", - status: "active", - launchState: "waiting_on_worker", - }); - const graph = createDelegationGraph({ - steps: [ - createDelegationStep({ - id: "step-existing", - stepKey: "planner-worker", - status: "running", - metadata: { - delegationContract: existing, - }, - }), - ], - }); - - const exclusiveConflict = hasConflictingDelegationContract({ - graph, - contract: createDelegationContract({ - contractId: "contract-next", - runId: "run-1", - workerIntent: "implementation", - mode: "exclusive", - scope: createDelegationScope({ - kind: "phase", - key: "phase:planning", - }), - }), - }); - expect(exclusiveConflict?.contractId).toBe("contract-existing"); - - const boundedExisting = createDelegationContract({ - contractId: "contract-bounded-existing", - runId: "run-1", - workerIntent: "specialist", - mode: "bounded_parallel", - scope: createDelegationScope({ - kind: "phase", - key: "phase:planning", - }), - activeWorkerIds: ["parallel-worker"], - status: "active", - launchState: "waiting_on_worker", - }); - const boundedGraph = createDelegationGraph({ - steps: [ - createDelegationStep({ - id: "step-bounded", - stepKey: "parallel-worker", - status: "running", - metadata: { - delegationContract: boundedExisting, - }, - }), - ], - }); - - const boundedParallelOverlap = hasConflictingDelegationContract({ - graph: boundedGraph, - contract: createDelegationContract({ - contractId: "contract-bounded", - runId: "run-1", - workerIntent: "specialist", - mode: "bounded_parallel", - scope: createDelegationScope({ - kind: "phase", - key: "phase:planning", - }), - }), - }); - expect(boundedParallelOverlap).toBeNull(); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts deleted file mode 100644 index e174c3e5f..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts +++ /dev/null @@ -1,845 +0,0 @@ -/** - * orchestratorQueries.ts - * - * Row types, SQL column constants, normalizer functions, and row-to-domain - * mappers extracted from orchestratorService.ts. These are pure functions - * that don't depend on factory closure state. - */ - -import type { - MissionStepHandoff, - OrchestratorAttempt, - OrchestratorAttemptResultEnvelope, - OrchestratorAttemptStatus, - OrchestratorClaim, - OrchestratorClaimScope, - OrchestratorClaimState, - OrchestratorContextPolicyProfile, - OrchestratorContextSnapshot, - OrchestratorContextSnapshotCursor, - OrchestratorErrorClass, - OrchestratorExecutorKind, - OrchestratorGateReport, - OrchestratorJoinPolicy, - OrchestratorRun, - OrchestratorRunStatus, - OrchestratorRuntimeBusEvent, - OrchestratorRuntimeEventType, - OrchestratorStep, - OrchestratorStepStatus, - OrchestratorTimelineEvent, - OrchestratorArtifact, - OrchestratorArtifactKind, -} from "../../../shared/types"; -import { parseJsonRecord, parseJsonArray } from "./orchestratorContext"; - -// ── Row Types ────────────────────────────────────────────────────── - -export type RunRow = { - id: string; - mission_id: string; - project_id: string; - status: string; - context_profile: string; - scheduler_state: string; - runtime_cursor_json: string | null; - last_error: string | null; - metadata_json: string | null; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; -}; - -export type StepRow = { - id: string; - run_id: string; - mission_step_id: string | null; - step_key: string; - step_index: number; - title: string; - lane_id: string | null; - status: string; - join_policy: string; - quorum_count: number | null; - dependency_step_ids_json: string; - retry_limit: number; - retry_count: number; - last_attempt_id: string | null; - policy_json: string | null; - metadata_json: string | null; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; -}; - -export type AttemptRow = { - id: string; - run_id: string; - step_id: string; - attempt_number: number; - status: string; - executor_kind: string; - executor_session_id: string | null; - tracked_session_enforced: number; - context_profile: string; - context_snapshot_id: string | null; - error_class: string; - error_message: string | null; - retry_backoff_ms: number; - result_envelope_json: string | null; - metadata_json: string | null; - created_at: string; - started_at: string | null; - completed_at: string | null; -}; - -export type ClaimRow = { - id: string; - run_id: string; - step_id: string | null; - attempt_id: string | null; - owner_id: string; - scope_kind: string; - scope_value: string; - state: string; - acquired_at: string; - heartbeat_at: string; - expires_at: string; - released_at: string | null; - policy_json: string | null; - metadata_json: string | null; -}; - -export type ContextSnapshotRow = { - id: string; - run_id: string; - step_id: string | null; - attempt_id: string | null; - snapshot_type: string; - context_profile: string; - cursor_json: string; - created_at: string; -}; - -export type HandoffRow = { - id: string; - mission_id: string; - mission_step_id: string | null; - run_id: string | null; - step_id: string | null; - attempt_id: string | null; - handoff_type: string; - producer: string; - payload_json: string; - created_at: string; -}; - -export type TimelineRow = { - id: string; - run_id: string; - step_id: string | null; - attempt_id: string | null; - claim_id: string | null; - event_type: string; - reason: string; - detail_json: string | null; - created_at: string; -}; - -export type RuntimeEventRow = { - id: string; - run_id: string; - step_id: string | null; - attempt_id: string | null; - session_id: string | null; - event_type: string; - event_key: string; - occurred_at: string; - payload_json: string | null; - created_at: string; -}; - -export type GateReportRow = { - id: string; - generated_at: string; - report_json: string; -}; - -export type ArtifactRow = { - id: string; - project_id: string; - mission_id: string; - run_id: string; - step_id: string; - attempt_id: string; - artifact_key: string; - kind: string; - value: string; - metadata_json: string; - declared: number; - created_at: string; -}; - -// ── Constants ────────────────────────────────────────────────────── - -export const DEFAULT_CONTEXT_PROFILE_ID = "orchestrator_deterministic_v1"; - -export const DEFAULT_CONTEXT_POLICY: OrchestratorContextPolicyProfile = { - id: DEFAULT_CONTEXT_PROFILE_ID, - docsMode: "digest_refs", - laneExportLevel: "standard", - projectExportLevel: "lite", - maxDocBytes: 120_000, -}; - -export const TERMINAL_RUN_STATUSES = new Set<OrchestratorRunStatus>(["succeeded", "failed", "canceled"]); -export const RETRYABLE_ERROR_CLASSES = new Set<OrchestratorErrorClass>([ - "transient", - "startup_failure", - "executor_failure", - "interrupted", - "claim_conflict", - "resume_recovered" -]); -export const MAX_TIMELINE_LIMIT = 1_000; - -export const GATE_THRESHOLDS = { - maxTrackedPipelineLatencyMs: 300_000, - minContextCompletenessRate: 0.98, - minFreshnessByTypeRate: 0.9, - maxBlockedInsufficientContextRate: 0.05, - freshnessMaxAgeByPackTypeMs: { - project: 24 * 60 * 60 * 1_000, - lane: 6 * 60 * 60 * 1_000, - feature: 48 * 60 * 60 * 1_000, - conflict: 2 * 60 * 60 * 1_000, - plan: 72 * 60 * 60 * 1_000, - mission: 24 * 60 * 60 * 1_000 - } as Record<string, number> -} as const; - -// ── Normalizer Functions ─────────────────────────────────────────── - -export function normalizeIsoTimestamp(value: unknown, fallbackIso: string): string { - const raw = typeof value === "string" ? value.trim() : ""; - if (!raw.length) return fallbackIso; - const ms = Date.parse(raw); - if (!Number.isFinite(ms)) return fallbackIso; - return new Date(ms).toISOString(); -} - -export function normalizeRunStatus(value: string): OrchestratorRunStatus { - if ( - value === "queued" || - value === "bootstrapping" || - value === "active" || - value === "paused" || - value === "completing" || - value === "succeeded" || - value === "failed" || - value === "canceled" - ) { - return value; - } - return "queued"; -} - -export function normalizeStepStatus(value: string): OrchestratorStepStatus { - if ( - value === "pending" || - value === "ready" || - value === "running" || - value === "succeeded" || - value === "failed" || - value === "blocked" || - value === "skipped" || - value === "superseded" || - value === "canceled" - ) { - return value; - } - return "pending"; -} - -export function normalizeAttemptStatus(value: string): OrchestratorAttemptStatus { - if ( - value === "queued" || - value === "running" || - value === "succeeded" || - value === "failed" || - value === "blocked" || - value === "canceled" - ) { - return value; - } - return "queued"; -} - -export function normalizeExecutorKind(value: string): OrchestratorExecutorKind { - if (value === "unified") { - return "opencode"; - } - if ( - value === "claude" - || value === "codex" - || value === "cursor" - || value === "droid" - || value === "opencode" - || value === "shell" - || value === "manual" - ) { - return value; - } - return "manual"; -} - -export function normalizeErrorClass(value: string): OrchestratorErrorClass { - if ( - value === "none" || - value === "transient" || - value === "deterministic" || - value === "policy" || - value === "claim_conflict" || - value === "startup_failure" || - value === "executor_failure" || - value === "interrupted" || - value === "configuration_error" || - value === "canceled" || - value === "resume_recovered" || - value === "planner_contract_violation" || - value === "soft_success_blocking_failure" - ) { - return value; - } - return "none"; -} - -export function normalizeJoinPolicy(value: string): OrchestratorJoinPolicy { - if (value === "all_success" || value === "any_success" || value === "quorum" || value === "advisory") return value; - return "all_success"; -} - -export function normalizeClaimScope(value: string): OrchestratorClaimScope { - if (value === "lane" || value === "file" || value === "env" || value === "task") return value; - return "lane"; -} - -export function normalizeClaimState(value: string): OrchestratorClaimState { - if (value === "active" || value === "released" || value === "expired") return value; - return "active"; -} - -export function normalizeRuntimeEventType(value: string): OrchestratorRuntimeEventType { - if ( - value === "progress" || - value === "heartbeat" || - value === "question" || - value === "blocked" || - value === "done" || - value === "retry_scheduled" || - value === "retry_exhausted" || - value === "planning_artifact_missing" || - value === "claim_conflict" || - value === "session_ended" || - value === "intervention_opened" || - value === "intervention_resolved" || - value === "coordinator_steering" || - value === "coordinator_broadcast" || - value === "coordinator_skip" || - value === "coordinator_add_step" || - value === "coordinator_pause" || - value === "coordinator_parallelize" || - value === "coordinator_consolidate" || - value === "coordinator_shutdown" || - value === "step_dependencies_updated" || - value === "step_metadata_updated" || - value === "fan_out_dispatched" || - value === "fan_out_complete" || - value === "worker_status_report" || - value === "worker_result_report" || - value === "worker_message" || - value === "plan_revised" || - value === "lane_transfer" || - value === "validation_report" || - value === "validation_contract_unfulfilled" || - value === "validation_self_check_reminder" || - value === "validation_auto_spawned" || - value === "validation_gate_blocked" || - value === "reflection_added" || - value === "retrospective_generated" || - value === "tool_profiles_updated" - ) { - return value; - } - return "progress"; -} - -export function normalizeProfileId(value: string | null | undefined): string { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : DEFAULT_CONTEXT_PROFILE_ID; -} - -export function normalizeTerminalSessionStatus(value: unknown): "running" | "completed" | "failed" | "disposed" | "unknown" { - const normalized = String(value ?? "").trim().toLowerCase(); - if (normalized === "running" || normalized === "completed" || normalized === "failed" || normalized === "disposed") { - return normalized; - } - return "unknown"; -} - -// ── Parse Helpers ────────────────────────────────────────────────── - -export function parseArray(raw: string | null): string[] { - return parseJsonArray(raw) - .map((entry) => String(entry ?? "").trim()) - .filter(Boolean); -} - -export function isExecutionPolicyRecord(value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const rec = value as Record<string, unknown>; - return "planning" in rec && "implementation" in rec; -} - -export function asBool(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -export function asIntInRange(value: unknown, fallback: number, min: number, max: number): number { - const raw = Number(value); - if (!Number.isFinite(raw)) return fallback; - return Math.max(min, Math.min(max, Math.floor(raw))); -} - -export function asNumberInRange(value: unknown, fallback: number, min: number, max: number): number { - const raw = Number(value); - if (!Number.isFinite(raw)) return fallback; - return Math.max(min, Math.min(max, raw)); -} - -export function asPositiveNumberOrNull(value: unknown): number | null { - const raw = Number(value); - if (!Number.isFinite(raw) || raw <= 0) return null; - return raw; -} - -// ── Row-to-Domain Mappers ────────────────────────────────────────── - -export function toRun(row: RunRow): OrchestratorRun { - return { - id: row.id, - missionId: row.mission_id, - projectId: row.project_id, - status: normalizeRunStatus(row.status), - contextProfile: normalizeProfileId(row.context_profile), - schedulerState: row.scheduler_state, - createdAt: row.created_at, - updatedAt: row.updated_at, - startedAt: row.started_at, - completedAt: row.completed_at, - lastError: row.last_error, - metadata: parseJsonRecord(row.metadata_json) - }; -} - -export function toStep(row: StepRow): OrchestratorStep { - return { - id: row.id, - runId: row.run_id, - missionStepId: row.mission_step_id, - stepKey: row.step_key, - stepIndex: Number(row.step_index ?? 0), - title: row.title, - laneId: row.lane_id, - status: normalizeStepStatus(row.status), - joinPolicy: normalizeJoinPolicy(row.join_policy), - quorumCount: row.quorum_count != null ? Number(row.quorum_count) : null, - dependencyStepIds: parseArray(row.dependency_step_ids_json), - retryLimit: Number(row.retry_limit ?? 0), - retryCount: Number(row.retry_count ?? 0), - lastAttemptId: row.last_attempt_id, - createdAt: row.created_at, - updatedAt: row.updated_at, - startedAt: row.started_at, - completedAt: row.completed_at, - metadata: parseJsonRecord(row.metadata_json) - }; -} - -function parseResultEnvelope(json: string | null): OrchestratorAttemptResultEnvelope | null { - if (!json) return null; - try { - return JSON.parse(json) as OrchestratorAttemptResultEnvelope; - } catch { - return null; - } -} - -export function toAttempt(row: AttemptRow): OrchestratorAttempt { - return { - id: row.id, - runId: row.run_id, - stepId: row.step_id, - attemptNumber: Number(row.attempt_number ?? 1), - status: normalizeAttemptStatus(row.status), - executorKind: normalizeExecutorKind(row.executor_kind), - executorSessionId: row.executor_session_id, - trackedSessionEnforced: row.tracked_session_enforced === 1, - contextProfile: normalizeProfileId(row.context_profile), - contextSnapshotId: row.context_snapshot_id, - errorClass: normalizeErrorClass(row.error_class), - errorMessage: row.error_message, - retryBackoffMs: Number(row.retry_backoff_ms ?? 0), - createdAt: row.created_at, - startedAt: row.started_at, - completedAt: row.completed_at, - resultEnvelope: parseResultEnvelope(row.result_envelope_json), - metadata: parseJsonRecord(row.metadata_json) - }; -} - -export function toClaim(row: ClaimRow): OrchestratorClaim { - return { - id: row.id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - ownerId: row.owner_id, - scopeKind: normalizeClaimScope(row.scope_kind), - scopeValue: row.scope_value, - state: normalizeClaimState(row.state), - acquiredAt: row.acquired_at, - heartbeatAt: row.heartbeat_at, - expiresAt: row.expires_at, - releasedAt: row.released_at, - policy: parseJsonRecord(row.policy_json), - metadata: parseJsonRecord(row.metadata_json) - }; -} - -function normalizeSnapshotType(value: string): "run" | "step" | "attempt" { - if (value === "step") return "step"; - if (value === "attempt") return "attempt"; - return "run"; -} - -const DEFAULT_CURSOR: OrchestratorContextSnapshotCursor = { - lanePackKey: null, - lanePackVersionId: null, - lanePackVersionNumber: null, - projectPackKey: "project", - projectPackVersionId: null, - projectPackVersionNumber: null, - packDeltaSince: null, - docs: [] -}; - -function parseCursorJson(json: string): OrchestratorContextSnapshotCursor { - try { - return JSON.parse(json) as OrchestratorContextSnapshotCursor; - } catch { - return { ...DEFAULT_CURSOR }; - } -} - -export function toContextSnapshot(row: ContextSnapshotRow): OrchestratorContextSnapshot { - return { - id: row.id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - snapshotType: normalizeSnapshotType(row.snapshot_type), - contextProfile: normalizeProfileId(row.context_profile), - cursor: parseCursorJson(row.cursor_json), - createdAt: row.created_at - }; -} - -export function toHandoff(row: HandoffRow): MissionStepHandoff { - return { - id: row.id, - missionId: row.mission_id, - missionStepId: row.mission_step_id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - handoffType: row.handoff_type, - producer: row.producer, - payload: parseJsonRecord(row.payload_json) ?? {}, - createdAt: row.created_at - }; -} - -export function toArtifact(row: ArtifactRow): OrchestratorArtifact { - const kind = row.kind as OrchestratorArtifactKind; - const validKinds: OrchestratorArtifactKind[] = ["file", "branch", "pr", "test_report", "checkpoint", "custom"]; - return { - id: row.id, - missionId: row.mission_id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - artifactKey: row.artifact_key, - kind: validKinds.includes(kind) ? kind : "custom", - value: row.value, - metadata: parseJsonRecord(row.metadata_json) ?? {}, - declared: row.declared === 1, - createdAt: row.created_at - }; -} - -export function toTimelineEvent(row: TimelineRow): OrchestratorTimelineEvent { - return { - id: row.id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - claimId: row.claim_id, - eventType: row.event_type, - reason: row.reason, - detail: parseJsonRecord(row.detail_json), - createdAt: row.created_at - }; -} - -export function toRuntimeEvent(row: RuntimeEventRow): OrchestratorRuntimeBusEvent { - const payload = parseJsonRecord(row.payload_json); - const threadId = typeof payload?.threadId === "string" ? payload.threadId.trim() : ""; - const messageId = typeof payload?.messageId === "string" ? payload.messageId.trim() : ""; - const replyToRaw = typeof payload?.replyTo === "string" ? payload.replyTo.trim() : ""; - const questionLink = - threadId.length > 0 && messageId.length > 0 - ? { - threadId, - messageId, - replyTo: replyToRaw.length > 0 ? replyToRaw : null - } - : null; - return { - id: row.id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - sessionId: row.session_id, - eventType: normalizeRuntimeEventType(row.event_type), - eventKey: row.event_key, - occurredAt: row.occurred_at, - payload, - questionLink, - createdAt: row.created_at - }; -} - -export function toGateReport(row: GateReportRow): OrchestratorGateReport | null { - try { - const parsed = JSON.parse(row.report_json) as OrchestratorGateReport; - if (!parsed || typeof parsed !== "object") return null; - return parsed; - } catch { - return null; - } -} - -export function normalizeEnvelope( - envelope: Partial<OrchestratorAttemptResultEnvelope> & { summary: string; success: boolean } -): OrchestratorAttemptResultEnvelope { - const warnings = Array.isArray(envelope.warnings) ? envelope.warnings.map((entry) => String(entry)) : []; - const outputs = - envelope.outputs && typeof envelope.outputs === "object" && !Array.isArray(envelope.outputs) - ? (envelope.outputs as Record<string, unknown>) - : null; - return { - schema: "ade.orchestratorAttempt.v1", - success: envelope.success, - summary: String(envelope.summary ?? "").trim() || (envelope.success ? "Step completed." : "Step failed."), - outputs, - warnings, - sessionId: typeof envelope.sessionId === "string" ? envelope.sessionId : null, - trackedSession: envelope.trackedSession !== false - }; -} - -// ── Graph Validation ─────────────────────────────────────────────── - -export type StepGraphValidationStep = { - stepKey: string; - dependencyStepKeys: string[]; - joinPolicy: OrchestratorJoinPolicy; - quorumCount: number | null; -}; - -export function normalizeDependencyStepKeys(dependencyStepKeys: string[] | undefined): string[] { - if (!Array.isArray(dependencyStepKeys)) return []; - const out: string[] = []; - const seen = new Set<string>(); - for (const raw of dependencyStepKeys) { - const key = String(raw ?? "").trim(); - if (!key.length) continue; - if (seen.has(key)) continue; - seen.add(key); - out.push(key); - } - return out; -} - -export function validateStepGraphIntegrity(args: { - context: "startRun" | "addSteps" | "updateStepDependencies" | "consolidateSteps"; - steps: StepGraphValidationStep[]; -}): void { - const byKey = new Map<string, StepGraphValidationStep>(); - for (const step of args.steps) { - const stepKey = step.stepKey.trim(); - if (!stepKey.length) { - throw new Error(`Encountered empty stepKey while validating ${args.context} graph.`); - } - if (byKey.has(stepKey)) { - throw new Error(`Duplicate stepKey in ${args.context} graph: ${stepKey}`); - } - byKey.set(stepKey, { - ...step, - stepKey, - dependencyStepKeys: normalizeDependencyStepKeys(step.dependencyStepKeys) - }); - } - - for (const step of byKey.values()) { - for (const dependencyKey of step.dependencyStepKeys) { - if (!byKey.has(dependencyKey)) { - throw new Error(`Unknown dependency stepKey '${dependencyKey}' referenced by step '${step.stepKey}'.`); - } - if (dependencyKey === step.stepKey) { - throw new Error(`Step '${step.stepKey}' cannot depend on itself.`); - } - } - - if (step.joinPolicy === "any_success" && step.dependencyStepKeys.length === 0) { - throw new Error(`Step '${step.stepKey}' uses joinPolicy=any_success without dependencies.`); - } - if (step.joinPolicy === "quorum") { - if (step.dependencyStepKeys.length === 0) { - throw new Error(`Step '${step.stepKey}' uses joinPolicy=quorum without dependencies.`); - } - if (step.quorumCount != null) { - const quorum = Number(step.quorumCount); - if (!Number.isFinite(quorum) || quorum <= 0 || Math.floor(quorum) > step.dependencyStepKeys.length) { - throw new Error( - `Step '${step.stepKey}' has quorumCount=${String(step.quorumCount)} outside valid range 1..${step.dependencyStepKeys.length}.` - ); - } - } - } - } - - const indegree = new Map<string, number>(); - const adjacency = new Map<string, string[]>(); - for (const step of byKey.values()) { - indegree.set(step.stepKey, 0); - adjacency.set(step.stepKey, []); - } - for (const step of byKey.values()) { - for (const dependencyKey of step.dependencyStepKeys) { - adjacency.get(dependencyKey)?.push(step.stepKey); - indegree.set(step.stepKey, (indegree.get(step.stepKey) ?? 0) + 1); - } - } - - const queue = [...indegree.entries()] - .filter(([, degree]) => degree === 0) - .map(([stepKey]) => stepKey); - let visited = 0; - while (queue.length > 0) { - const current = queue.shift()!; - visited += 1; - for (const dependentKey of adjacency.get(current) ?? []) { - const nextDegree = (indegree.get(dependentKey) ?? 0) - 1; - indegree.set(dependentKey, nextDegree); - if (nextDegree === 0) { - queue.push(dependentKey); - } - } - } - - if (visited !== byKey.size) { - throw new Error(`Dependency cycle detected in ${args.context} step graph.`); - } -} - -// ── Miscellaneous Helpers ────────────────────────────────────────── - -export function branchNameFromRef(ref: string): string { - const trimmed = ref.trim(); - if (!trimmed.length) return ""; - return trimmed.startsWith("refs/heads/") ? trimmed.slice("refs/heads/".length) : trimmed; -} - -export function clipText(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, Math.max(0, maxChars - 14))}\n...<truncated>`; -} - -// ── Blocking Warning Classification ──────────────────────────────── - -/** - * Classify warnings from a succeeded attempt to detect blocking failures - * that should override the success status. - */ -export type BlockingWarningClassification = { - hasBlockingFailure: boolean; - category: 'sandbox_block' | 'permission_denied' | 'tool_failure' | 'missing_auth' | 'worker_no_output' | 'planner_contract_violation' | null; - detail: string | null; -}; - -const BLOCKING_WARNING_PATTERNS: Array<{ pattern: RegExp; category: BlockingWarningClassification['category'] }> = [ - { pattern: /PLANNER CONTRACT VIOLATION/i, category: 'planner_contract_violation' }, - { pattern: /SANDBOX BLOCKED/i, category: 'sandbox_block' }, - { pattern: /sandbox.?block/i, category: 'sandbox_block' }, - { pattern: /File path outside sandbox/i, category: 'sandbox_block' }, - { pattern: /PreToolUse:\w+ hook error/i, category: 'tool_failure' }, - { pattern: /permission denied/i, category: 'permission_denied' }, - { pattern: /EPERM|EACCES/i, category: 'permission_denied' }, - { pattern: /validation failed for tool/i, category: 'tool_failure' }, - { pattern: /zod validation.*tool/i, category: 'tool_failure' }, - { pattern: /tool .+ failed/i, category: 'tool_failure' }, - { pattern: /tool startup fail/i, category: 'tool_failure' }, - { pattern: /needs-auth/i, category: 'missing_auth' }, - { pattern: /authentication required/i, category: 'missing_auth' }, - { pattern: /unauthorized/i, category: 'missing_auth' }, -]; - -// Provider connector auth warnings that should NOT be treated as blocking. -const PROVIDER_CONNECTOR_NOISE_PATTERNS: RegExp[] = [ - /claude\.ai\s+\S+:needs-auth/i, - /claude\.ai\s+Gmail/i, - /claude\.ai\s+Google Calendar/i, - /claude\.ai\s+Google Drive/i, - /claude\.ai\s+Slack/i, -]; - -export function classifyBlockingWarnings(args: { - warnings: string[]; - summary: string | null; -}): BlockingWarningClassification { - const { warnings, summary } = args; - - // Combine all text to scan - const textsToScan = [...warnings]; - if (summary) textsToScan.push(summary); - - for (const text of textsToScan) { - const isExternalNoise = PROVIDER_CONNECTOR_NOISE_PATTERNS.some(p => p.test(text)); - if (isExternalNoise) continue; - - for (const { pattern, category } of BLOCKING_WARNING_PATTERNS) { - if (pattern.test(text)) { - return { - hasBlockingFailure: true, - category, - detail: text.slice(0, 500), - }; - } - } - } - - return { hasBlockingFailure: false, category: null, detail: null }; -} diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts deleted file mode 100644 index fcee5c824..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts +++ /dev/null @@ -1,9033 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import type { PackExport, PackType } from "../../../shared/types"; -import { createOrchestratorService, ReflectionValidationError } from "./orchestratorService"; -import { openKvDb } from "../state/kvDb"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function runGit(cwd: string, args: string[]) { - const result = spawnSync("git", ["-C", cwd, ...args], { - encoding: "utf8" - }); - if (result.status === 0) return; - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - throw new Error(`git ${args.join(" ")} failed (${result.status}): ${stderr}`); -} - -function buildExport(packKey: string, packType: PackType, level: "lite" | "standard" | "deep"): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null - }; -} - -async function createFixture(args: { - conflictService?: any; - projectConfigService?: Record<string, unknown> | null; - aiIntegrationService?: Record<string, unknown> | null; - onEvent?: (event: any) => void; -} = {}) { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-orchestrator-")); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nContext baseline\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "CONTEXT_CONTRACT.md"), "# Context Contract\n", "utf8"); - - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-02-19T00:00:00.000Z"; - - db.run( - ` - insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?) - `, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - laneId, - projectId, - "Lane 1", - null, - "worktree", - "main", - "feature/lane-1", - projectRoot, - null, - 0, - null, - null, - null, - null, - "active", - now, - null - ] - ); - - db.run( - ` - insert into missions( - id, - project_id, - lane_id, - title, - prompt, - status, - priority, - execution_mode, - target_machine_id, - outcome_summary, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [missionId, projectId, laneId, "Mission 1", "Execute deterministic run.", "queued", "normal", "local", null, null, null, null, now, now, null, null] - ); - - const ptyCreateCalls: Array<Record<string, unknown>> = []; - const ptyService = { - create: async (args: Record<string, unknown>) => { - ptyCreateCalls.push(args); - const index = ptyCreateCalls.length; - return { - ptyId: `pty-${index}`, - sessionId: `session-${index}` - }; - } - } as any; - - const service = createOrchestratorService({ - db, - projectId, - projectRoot, - conflictService: args.conflictService, - ptyService, - projectConfigService: (args.projectConfigService ?? null) as any, - aiIntegrationService: (args.aiIntegrationService ?? null) as any, - onEvent: args.onEvent, - }); - - // Test harness convenience: opencode workers require metadata.modelId in Phase 3. - // Inject a default modelId for tests that are validating unrelated behavior. - const defaultOpenCodeModelId = "anthropic/claude-sonnet-4-6"; - const normalizeStepModelId = (step: any) => { - const executorKind = typeof step?.executorKind === "string" ? step.executorKind : null; - if (executorKind !== "opencode") return step; - const metadata = - step?.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata) - ? step.metadata - : {}; - const modelId = typeof metadata.modelId === "string" ? metadata.modelId.trim() : ""; - if (modelId.length > 0) return step; - return { - ...step, - metadata: { - ...metadata, - modelId: defaultOpenCodeModelId, - }, - }; - }; - const originalStartRun = service.startRun.bind(service); - (service as any).startRun = ((input: any) => - originalStartRun({ - ...input, - steps: Array.isArray(input?.steps) ? input.steps.map((step: any) => normalizeStepModelId(step)) : input?.steps, - })) as typeof service.startRun; - - return { - db, - service, - projectId, - projectRoot, - laneId, - missionId, - ptyCreateCalls, - dispose: () => db.close() - }; -} - -describe("orchestratorService", () => { - it("enforces tracked sessions for orchestrated execution", async () => { - const fixture = await createFixture(); - try { - await expect( - fixture.service.createOrchestratedSession({ - laneId: fixture.laneId, - cols: 120, - rows: 36, - title: "orchestrator session", - tracked: false - }) - ).rejects.toThrow(/tracked=true/i); - - const created = await fixture.service.createOrchestratedSession({ - laneId: fixture.laneId, - cols: 120, - rows: 36, - title: "orchestrator session" - }); - - expect(created.sessionId).toBe("session-1"); - expect(fixture.ptyCreateCalls).toHaveLength(1); - expect(fixture.ptyCreateCalls[0]?.tracked).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("blocks attempts deterministically on claim collisions", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "build", - title: "Build", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "lane", scopeValue: `lane:${fixture.laneId}`, ttlMs: 60_000 }] - } - }, - { - stepKey: "test", - title: "Test", - stepIndex: 1, - policy: { - claimScopes: [{ scopeKind: "lane", scopeValue: `lane:${fixture.laneId}`, ttlMs: 60_000 }] - } - } - ] - }); - const [firstStep, secondStep] = fixture.service.listSteps(started.run.id); - - const firstAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: firstStep!.id, - ownerId: "owner-a" - }); - expect(firstAttempt.status).toBe("running"); - - const secondAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: secondStep!.id, - ownerId: "owner-b" - }); - expect(secondAttempt.status).toBe("blocked"); - expect(secondAttempt.errorClass).toBe("claim_conflict"); - - const activeClaims = fixture.service.listClaims({ runId: started.run.id, state: "active" }); - expect(activeClaims).toHaveLength(1); - expect(activeClaims[0]?.scopeValue).toBe(`lane:${fixture.laneId}`); - } finally { - fixture.dispose(); - } - }); - - it("keeps cancellation and resume deterministic when claim conflicts are active", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "lane-lock-a", - title: "Lane Lock A", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "lane", scopeValue: `lane:${fixture.laneId}`, ttlMs: 60_000 }] - } - }, - { - stepKey: "lane-lock-b", - title: "Lane Lock B", - stepIndex: 1, - policy: { - claimScopes: [{ scopeKind: "lane", scopeValue: `lane:${fixture.laneId}`, ttlMs: 60_000 }] - } - } - ] - }); - const [firstStep, secondStep] = fixture.service.listSteps(started.run.id); - if (!firstStep || !secondStep) throw new Error("Missing steps"); - - const runningAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: firstStep.id, - ownerId: "owner-running" - }); - expect(runningAttempt.status).toBe("running"); - - const blockedAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: secondStep.id, - ownerId: "owner-blocked" - }); - expect(blockedAttempt.status).toBe("blocked"); - expect(blockedAttempt.errorClass).toBe("claim_conflict"); - - fixture.service.cancelRun({ - runId: started.run.id, - reason: "operator_cancel_conflict" - }); - - const canceledGraph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 50 }); - expect(canceledGraph.run.status).toBe("canceled"); - expect(canceledGraph.steps.every((step) => step.status === "canceled")).toBe(true); - expect(canceledGraph.attempts.some((attempt) => attempt.status === "canceled")).toBe(true); - - const activeClaims = fixture.service.listClaims({ runId: started.run.id, state: "active" }); - expect(activeClaims).toHaveLength(0); - - const resumed = fixture.service.resumeRun({ runId: started.run.id }); - expect(resumed.status).toBe("canceled"); - - const runtimeEvents = fixture.service.listRuntimeEvents({ - runId: started.run.id, - eventTypes: ["claim_conflict"], - limit: 20 - }); - expect(runtimeEvents.length).toBeGreaterThan(0); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline.some((entry) => entry.eventType === "run_canceled")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("persists runtime bus events idempotently and replays them from run graph", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "runtime", title: "Runtime", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "runtime-owner" - }); - - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "heartbeat", - eventKey: "dedupe-key", - payload: { source: "test" } - }); - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "heartbeat", - eventKey: "dedupe-key", - payload: { source: "test-duplicate" } - }); - - const events = fixture.service.listRuntimeEvents({ - runId: started.run.id, - eventTypes: ["heartbeat"], - limit: 10 - }); - const deduped = events.filter((event) => event.eventKey === "dedupe-key"); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.payload?.source).toBe("test"); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - expect((graph.runtimeEvents ?? []).some((event) => event.eventKey === "dedupe-key")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("blocks overlapping file reservation patterns under parallel load", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "file-a", - title: "File A", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "file", scopeValue: "glob:src/**", ttlMs: 60_000 }] - } - }, - { - stepKey: "file-b", - title: "File B", - stepIndex: 1, - policy: { - claimScopes: [{ scopeKind: "file", scopeValue: "glob:src/app/**", ttlMs: 60_000 }] - } - } - ] - }); - const [first, second] = fixture.service.listSteps(started.run.id); - if (!first || !second) throw new Error("Missing steps"); - - const firstAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: first.id, - ownerId: "owner-a" - }); - expect(firstAttempt.status).toBe("running"); - - const secondAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: second.id, - ownerId: "owner-b" - }); - expect(secondAttempt.status).toBe("blocked"); - expect(secondAttempt.errorClass).toBe("claim_conflict"); - expect(secondAttempt.errorMessage ?? "").toContain("Claim collision"); - - const metadata = secondAttempt.metadata ?? {}; - expect(metadata.claimConflict).toBeTruthy(); - expect(String((metadata.claimConflict as Record<string, unknown>).conflictReason ?? "")).toContain("overlapping_file_scope"); - } finally { - fixture.dispose(); - } - }); - - it("warns on file reservation violations at completion boundary in warn mode", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - fileReservationGuardMode: "warn" - } - } - } - }) - } - }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "guard-warn", - title: "Guard Warn", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "file", scopeValue: "glob:src/**", ttlMs: 60_000 }] - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner-warn" - }); - expect(attempt.status).toBe("running"); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: { - changedFiles: ["src/app/main.ts", "README.md"] - } - }); - expect(completed.status).toBe("succeeded"); - expect( - (completed.resultEnvelope?.warnings ?? []).some((entry: string) => entry.includes("File reservation violation")) - ).toBe(true); - expect(Array.isArray(completed.metadata?.fileReservationViolations)).toBe(true); - expect((completed.metadata?.fileReservationViolations as string[])).toContain("README.md"); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - const guard = timeline.find((entry) => entry.eventType === "file_reservation_guard"); - expect(guard?.reason).toBe("warn"); - } finally { - fixture.dispose(); - } - }); - - it("blocks completion on file reservation violations in block mode", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - fileReservationGuardMode: "block" - } - } - } - }) - } - }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "guard-block", - title: "Guard Block", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "file", scopeValue: "glob:src/**", ttlMs: 60_000 }] - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner-block" - }); - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: { - changedFiles: ["README.md"] - } - }); - expect(completed.status).toBe("blocked"); - expect(completed.errorClass).toBe("policy"); - expect(completed.errorMessage ?? "").toContain("File reservation violation"); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const refreshedStep = graph.steps.find((entry) => entry.id === step.id); - expect(refreshedStep?.status).toBe("blocked"); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - const guard = timeline.find((entry) => entry.eventType === "file_reservation_guard"); - expect(guard?.reason).toBe("block"); - } finally { - fixture.dispose(); - } - }); - - it("treats rename/move paths as touched files for reservation enforcement", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - fileReservationGuardMode: "warn" - } - } - } - }) - } - }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "rename-edge", - title: "Rename Edge", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "file", scopeValue: "glob:src/**", ttlMs: 60_000 }] - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner-rename" - }); - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: { - renamedFiles: [{ from: "src/legacy.ts", to: "docs/legacy.ts" }] - } - }); - expect(completed.status).toBe("succeeded"); - expect((completed.metadata?.fileReservationTouchedPaths as string[])).toContain("src/legacy.ts"); - expect((completed.metadata?.fileReservationTouchedPaths as string[])).toContain("docs/legacy.ts"); - expect((completed.metadata?.fileReservationViolations as string[])).toContain("docs/legacy.ts"); - expect((completed.metadata?.fileReservationViolations as string[])).not.toContain("src/legacy.ts"); - } finally { - fixture.dispose(); - } - }); - - it("uses git status fallback for staged and unstaged touched-file reservation checks", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - fileReservationGuardMode: "block" - } - } - } - }) - } - }); - try { - runGit(fixture.projectRoot, ["init"]); - runGit(fixture.projectRoot, ["config", "user.email", "test@example.com"]); - runGit(fixture.projectRoot, ["config", "user.name", "ADE Test"]); - - fs.mkdirSync(path.join(fixture.projectRoot, "src"), { recursive: true }); - fs.writeFileSync(path.join(fixture.projectRoot, "src", "in-scope.ts"), "export const value = 1;\n", "utf8"); - runGit(fixture.projectRoot, ["add", "-A"]); - runGit(fixture.projectRoot, ["commit", "-m", "baseline"]); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "git-fallback", - title: "Git Fallback", - stepIndex: 0, - laneId: fixture.laneId, - policy: { - claimScopes: [{ scopeKind: "file", scopeValue: "glob:src/**", ttlMs: 60_000 }] - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner-git-fallback" - }); - - fs.writeFileSync(path.join(fixture.projectRoot, "src", "in-scope.ts"), "export const value = 2;\n", "utf8"); - fs.mkdirSync(path.join(fixture.projectRoot, "docs"), { recursive: true }); - fs.writeFileSync(path.join(fixture.projectRoot, "docs", "out-of-scope.md"), "changed\n", "utf8"); - runGit(fixture.projectRoot, ["add", "src/in-scope.ts"]); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: {} - }); - - expect(completed.status).toBe("blocked"); - expect(completed.errorClass).toBe("policy"); - expect(completed.errorMessage ?? "").toContain("File reservation violation"); - expect((completed.metadata?.fileReservationTouchedPaths as string[])).toContain("src/in-scope.ts"); - expect((completed.metadata?.fileReservationTouchedPaths as string[])).toContain("docs/out-of-scope.md"); - expect((completed.metadata?.fileReservationViolations as string[])).toContain("docs/out-of-scope.md"); - expect((completed.metadata?.fileReservationViolations as string[])).not.toContain("src/in-scope.ts"); - } finally { - fixture.dispose(); - } - }); - - it("recovers running attempts into deterministic resume path", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "apply", - title: "Apply patch", - stepIndex: 0, - retryLimit: 1 - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - expect(step?.status).toBe("ready"); - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - expect(attempt.status).toBe("running"); - - fixture.service.activateRun(started.run.id); - const resumed = fixture.service.resumeRun({ runId: started.run.id }); - expect(resumed.status).toBe("active"); - - const attempts = fixture.service.listAttempts({ runId: started.run.id }); - const recovered = attempts.find((entry) => entry.id === attempt.id); - expect(recovered?.status).toBe("failed"); - expect(recovered?.errorClass).toBe("resume_recovered"); - - const updatedStep = fixture.service.listSteps(started.run.id)[0]; - expect(updatedStep?.status).toBe("ready"); - - const handoff = fixture.service - .listHandoffs({ runId: started.run.id }) - .find((entry) => entry.attemptId === attempt.id && entry.handoffType === "attempt_recovered_after_restart"); - expect(handoff).toBeTruthy(); - } finally { - fixture.dispose(); - } - }); - - it("grants one infrastructure retry when restart recovery finds a zero-retry step", async () => { - const events: any[] = []; - const fixture = await createFixture({ onEvent: (event) => events.push(event) }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "apply", - title: "Apply patch", - stepIndex: 0, - retryLimit: 0 - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - fixture.service.activateRun(started.run.id); - - fixture.service.resumeRun({ runId: started.run.id }); - - const updatedStep = fixture.service.listSteps(started.run.id)[0]; - expect(updatedStep?.status).toBe("ready"); - expect(updatedStep?.retryCount).toBe(0); - expect(updatedStep?.metadata?.resumeRecoveryCount).toBe(1); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "orchestrator-attempt-updated", - attemptId: attempt.id, - reason: "completed" - }), - expect.objectContaining({ - type: "orchestrator-step-updated", - stepId: step.id, - reason: "attempt_completed" - }) - ]) - ); - } finally { - fixture.dispose(); - } - }); - - it("requeues shutdown-interrupted failures without consuming step retry budget", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "reload-safe", - title: "Reload Safe", - stepIndex: 0, - retryLimit: 1 - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage: "Chat session 'session-1' was closed during shutdown." - }); - - const updatedStep = fixture.service.listSteps(started.run.id)[0]; - expect(updatedStep?.status).toBe("ready"); - expect(updatedStep?.retryCount).toBe(0); - expect(updatedStep?.metadata?.restartInterruptedRetryCount).toBe(1); - - const updatedRun = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - expect(updatedRun?.lastError).toBeNull(); - } finally { - fixture.dispose(); - } - }); - - it("resumeRun restores failed legacy shutdown attempts into the scheduler", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "legacy-reload", - title: "Legacy Reload", - stepIndex: 0, - retryLimit: 1 - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - const completedAt = "2026-03-08T00:10:00.000Z"; - fixture.db.run( - ` - update orchestrator_attempts - set status = 'failed', - error_class = 'executor_failure', - error_message = ?, - completed_at = ? - where id = ? - `, - ["Chat session 'session-legacy' was closed during shutdown.", completedAt, attempt.id] - ); - fixture.db.run( - ` - update orchestrator_steps - set status = 'failed', - retry_count = 1, - last_attempt_id = ?, - completed_at = ?, - updated_at = ? - where id = ? - `, - [attempt.id, completedAt, completedAt, step.id] - ); - fixture.db.run( - ` - update orchestrator_runs - set status = 'active', - last_error = ? - where id = ? - `, - ["Chat session 'session-legacy' was closed during shutdown.", started.run.id] - ); - - const resumed = fixture.service.resumeRun({ runId: started.run.id }); - expect(resumed.status).toBe("active"); - - const updatedStep = fixture.service.listSteps(started.run.id)[0]; - expect(updatedStep?.status).toBe("ready"); - expect(updatedStep?.retryCount).toBe(0); - expect(updatedStep?.metadata?.restartInterruptedFailureRecovered).toBe(true); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline.some((entry) => entry.eventType === "attempt_recovered_after_restart")).toBe(true); - const updatedRun = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - expect(updatedRun?.lastError).toBeNull(); - } finally { - fixture.dispose(); - } - }); - - it("blocks startAttempt and autopilot dispatch when run is paused or completing", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [ - { - stepKey: "status-guarded", - title: "Status Guarded", - stepIndex: 0, - executorKind: "opencode" - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - fixture.service.pauseRun({ runId: started.run.id, reason: "operator pause" }); - await expect( - fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }) - ).rejects.toThrow(/status 'paused'/i); - expect( - await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "paused_guard" - }) - ).toBe(0); - - fixture.db.run(`update orchestrator_runs set status = 'completing', updated_at = ? where id = ?`, [new Date().toISOString(), started.run.id]); - await expect( - fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }) - ).rejects.toThrow(/status 'completing'/i); - expect( - await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "completing_guard" - }) - ).toBe(0); - - expect(fixture.service.listAttempts({ runId: started.run.id })).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("rejects executor-backed attempts before scaffolding when laneId is missing", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "missing-lane", - title: "Missing lane", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - } - ] - }); - const createdStep = fixture.service.listSteps(started.run.id)[0]; - if (!createdStep) throw new Error("Missing step"); - - fixture.db.run( - `update orchestrator_steps set lane_id = null, updated_at = ? where id = ? and project_id = ?`, - [new Date().toISOString(), createdStep.id, fixture.projectId], - ); - const refreshedStep = fixture.service.listSteps(started.run.id)[0]; - expect(refreshedStep?.laneId).toBeNull(); - - await expect( - fixture.service.startAttempt({ - runId: started.run.id, - stepId: refreshedStep?.id ?? createdStep.id, - ownerId: "owner", - executorKind: "opencode", - }), - ).rejects.toThrow(/laneId is missing/i); - expect(fixture.service.listAttempts({ runId: started.run.id })).toHaveLength(0); - expect(fixture.ptyCreateCalls).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("defaults added mission steps to the persisted mission lane", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - missionLaneId: fixture.laneId, - }, - steps: [], - }); - - const created = fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "mission-task", - title: "Mission task", - stepIndex: 0, - executorKind: "manual", - }, - ], - }); - - expect(created[0]?.laneId).toBe(fixture.laneId); - expect(fixture.service.listSteps(started.run.id)[0]?.laneId).toBe(fixture.laneId); - } finally { - fixture.dispose(); - } - }); - - it("still launches ready workers even when manual task steps are present", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [ - { - stepKey: "plan", - title: "Plan sidebar work", - stepIndex: 0, - executorKind: "manual", - metadata: { - stepType: "task" - } - }, - { - stepKey: "planning-worker", - title: "Research sidebar", - stepIndex: 1, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "planning" - } - } - ] - }); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "ignore_display_only_tasks" - }); - - expect(startedAttempts).toBe(1); - const attempts = fixture.service.listAttempts({ runId: started.run.id }); - const planningWorkerStep = fixture.service - .listSteps(started.run.id) - .find((step) => step.stepKey === "planning-worker"); - - expect(attempts).toHaveLength(1); - expect(attempts[0]?.stepId).toBe(planningWorkerStep?.id); - - } finally { - fixture.dispose(); - } - }); - - it("allows planner recovery autopilot through an open planner-plan-missing intervention only for planning steps", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 2, - }, - }, - steps: [ - { - stepKey: "planning-recovery", - title: "Recover planning", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "planning", - taskType: "planning", - phaseKey: "planning", - }, - }, - { - stepKey: "implementation-while-blocked", - title: "Implementation while blocked", - stepIndex: 1, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "development", - }, - }, - ], - }); - const now = new Date().toISOString(); - const planningStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "planning-recovery"); - const implementationStep = fixture.service - .listSteps(started.run.id) - .find((step) => step.stepKey === "implementation-while-blocked"); - if (!planningStep || !implementationStep) throw new Error("Missing test steps"); - - fixture.db.run( - `update missions set status = 'intervention_required', updated_at = ? where id = ? and project_id = ?`, - [now, fixture.missionId, fixture.projectId], - ); - fixture.db.run( - ` - insert into mission_interventions( - id, - mission_id, - project_id, - intervention_type, - status, - title, - body, - requested_action, - lane_id, - metadata_json, - created_at, - updated_at - ) values (?, ?, ?, 'failed_step', 'open', ?, ?, ?, ?, ?, ?, ?) - `, - [ - "planner-plan-missing-intervention", - fixture.missionId, - fixture.projectId, - "Planner result missing plan", - "Planning worker completed without returning report_result.plan.markdown.", - "Retry planning and return report_result.plan.markdown.", - fixture.laneId, - JSON.stringify({ - runId: started.run.id, - stepId: "failed-planner-step", - reasonCode: "planner_plan_missing", - }), - now, - now, - ], - ); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "planner_recovery", - }); - - const attempts = fixture.service.listAttempts({ runId: started.run.id }); - expect(startedAttempts).toBe(1); - expect(attempts).toHaveLength(1); - expect(attempts[0]?.stepId).toBe(planningStep.id); - expect(fixture.service.listSteps(started.run.id).find((step) => step.id === implementationStep.id)?.status).toBe("ready"); - } finally { - fixture.dispose(); - } - }); - - it("keeps non-planner recovery interventions blocking autopilot", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1, - }, - }, - steps: [ - { - stepKey: "blocked-planning", - title: "Blocked planning", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "planning", - phaseKey: "planning", - }, - }, - ], - }); - const now = new Date().toISOString(); - fixture.db.run( - `update missions set status = 'intervention_required', updated_at = ? where id = ? and project_id = ?`, - [now, fixture.missionId, fixture.projectId], - ); - fixture.db.run( - ` - insert into mission_interventions( - id, - mission_id, - project_id, - intervention_type, - status, - title, - body, - requested_action, - lane_id, - metadata_json, - created_at, - updated_at - ) values (?, ?, ?, 'failed_step', 'open', ?, ?, ?, ?, ?, ?, ?) - `, - [ - "ordinary-failed-step-intervention", - fixture.missionId, - fixture.projectId, - "Worker failed", - "A non-planning worker failed.", - "Review the failed worker before continuing.", - fixture.laneId, - JSON.stringify({ - runId: started.run.id, - reasonCode: "worker_failed", - }), - now, - now, - ], - ); - - expect( - await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "non_planner_intervention", - }), - ).toBe(0); - expect(fixture.service.listAttempts({ runId: started.run.id })).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("keeps future-phase steps pending until the active phase changes", async () => { - const fixture = await createFixture(); - try { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work", - instructions: "Research first.", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false, criteria: "" }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - const developmentPhase = { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Implement the work", - instructions: "Write code.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false, criteria: "" }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [planningPhase, developmentPhase] }, - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 2 - } - }, - steps: [ - { - stepKey: "plan-work", - title: "Plan work", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "planning", - phaseKey: "planning", - phaseName: "Planning", - phasePosition: 0, - } - }, - { - stepKey: "impl-work", - title: "Implement work", - stepIndex: 1, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "development", - phaseName: "Development", - phasePosition: 1, - } - } - ] - }); - - const planningStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "plan-work"); - const implementationStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl-work"); - expect(planningStep?.status).toBe("ready"); - expect(implementationStep?.status).toBe("pending"); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "phase_gate_regression" - }); - - expect(startedAttempts).toBe(1); - const attempts = fixture.service.listAttempts({ runId: started.run.id }); - expect(attempts).toHaveLength(1); - expect(attempts[0]?.stepId).toBe(planningStep?.id); - const refreshedImplementation = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl-work"); - expect(refreshedImplementation?.status).toBe("pending"); - } finally { - fixture.dispose(); - } - }); - - it("auto-advances to the next configured phase when current phase is complete and downstream work exists", async () => { - const fixture = await createFixture(); - try { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work", - instructions: "Research first.", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false, criteria: "" }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - const developmentPhase = { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Implement the work", - instructions: "Write code.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false, criteria: "" }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [planningPhase, developmentPhase] }, - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - }, - steps: [ - { - stepKey: "plan-work", - title: "Plan work", - stepIndex: 0, - executorKind: "manual", - metadata: { - stepType: "planning", - phaseKey: "planning", - phaseName: "Planning", - readOnlyExecution: true, - } - }, - { - stepKey: "impl-work", - title: "Implement work", - stepIndex: 1, - dependencyStepKeys: ["plan-work"], - executorKind: "manual", - metadata: { - stepType: "implementation", - phaseKey: "development", - phaseName: "Development", - } - } - ] - }); - - const planningStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "plan-work"); - const implementationStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl-work"); - if (!planningStep || !implementationStep) throw new Error("Missing phase auto-advance steps"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: planningStep.id, - ownerId: "planner-owner", - executorKind: "manual", - }); - fixture.service.updateStepMetadata({ - runId: started.run.id, - stepId: planningStep.id, - metadata: { - lastResultReport: { - summary: "Planning complete.", - plan: { - markdown: "# Plan\n\n1. Implement work", - artifactPath: ".ade/plans/mission-plan.md", - }, - }, - }, - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Planning complete.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - const refreshed = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 50 }); - const phaseRuntime = refreshed.run.metadata?.phaseRuntime as Record<string, unknown> | undefined; - const developmentStep = refreshed.steps.find((step) => step.id === implementationStep.id); - - expect(phaseRuntime?.currentPhaseKey).toBe("development"); - expect(phaseRuntime?.currentPhaseName).toBe("Development"); - expect(developmentStep?.status).toBe("ready"); - expect( - refreshed.timeline.some((entry) => entry.eventType === "phase_transition" && entry.reason === "kernel_auto_advance") - ).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("does not require plan.markdown from read-only non-planning workers", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "read-only-development", - title: "Read-only development check", - stepIndex: 0, - executorKind: "manual", - metadata: { - stepType: "implementation", - phaseKey: "development", - phaseName: "Development", - readOnlyExecution: true, - }, - }, - ], - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing read-only development step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "development-owner", - executorKind: "manual", - }); - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Read-only development check completed.", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - expect(completed.status).toBe("succeeded"); - expect(completed.errorClass).toBe("none"); - expect(completed.errorMessage).toBeNull(); - } finally { - fixture.dispose(); - } - }); - - it("does not auto-advance a phase while required self-validation is pending", async () => { - const fixture = await createFixture(); - try { - const implementationPhase = { - id: "phase-implementation", - phaseKey: "implementation", - name: "Implementation", - description: "Build", - instructions: "Implement.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true, criteria: "Implementation must be validated." }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - const testingPhase = { - id: "phase-testing", - phaseKey: "testing", - name: "Testing", - description: "Test", - instructions: "Run tests.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["implementation"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true, criteria: "Tests must pass." }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [implementationPhase, testingPhase] }, - phaseRuntime: { - currentPhaseKey: "implementation", - currentPhaseName: "Implementation", - currentPhaseModel: implementationPhase.model, - }, - }, - steps: [ - { - stepKey: "impl-work", - title: "Implement work", - stepIndex: 0, - executorKind: "manual", - metadata: { - stepType: "implementation", - phaseKey: "implementation", - phaseName: "Implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Implementation must be validated.", - evidence: [], - maxRetries: 2, - }, - }, - }, - { - stepKey: "test-work", - title: "Test work", - stepIndex: 1, - dependencyStepKeys: ["impl-work"], - executorKind: "manual", - metadata: { - stepType: "testing", - phaseKey: "testing", - phaseName: "Testing", - }, - }, - ], - }); - - const implStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl-work"); - const testStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "test-work"); - if (!implStep || !testStep) throw new Error("Missing phase validation gate steps"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: implStep.id, - ownerId: "owner", - executorKind: "manual", - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Implementation complete.", - outputs: { filesChanged: ["src/index.ts"] }, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - const beforeValidation = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 50 }); - expect((beforeValidation.run.metadata?.phaseRuntime as Record<string, unknown>)?.currentPhaseKey).toBe("implementation"); - expect(beforeValidation.steps.find((step) => step.id === testStep.id)?.status).toBe("pending"); - - const implAfterCompletion = beforeValidation.steps.find((step) => step.id === implStep.id); - fixture.db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ?`, - [ - JSON.stringify({ - ...(implAfterCompletion?.metadata ?? {}), - validationState: "pass", - validationPassedAt: "2026-03-08T00:05:00.000Z", - }), - "2026-03-08T00:05:00.000Z", - implStep.id, - ], - ); - fixture.service.tick({ runId: started.run.id }); - - const afterValidation = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 50 }); - expect((afterValidation.run.metadata?.phaseRuntime as Record<string, unknown>)?.currentPhaseKey).toBe("testing"); - expect(afterValidation.steps.find((step) => step.id === testStep.id)?.status).toBe("ready"); - } finally { - fixture.dispose(); - } - }); - - it("keeps terminal display-only tracker steps terminal during readiness refresh", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "planner-launch-tracker", - title: "Launch planning worker", - stepIndex: -1000, - executorKind: "manual", - metadata: { - plannerLaunchTracker: true, - phaseKey: "planning", - phaseName: "Planning", - }, - }, - ], - }); - const tracker = fixture.service.listSteps(started.run.id)[0]; - if (!tracker) throw new Error("Missing tracker step"); - fixture.db.run( - `update orchestrator_steps set status = 'succeeded', completed_at = ?, updated_at = ? where id = ?`, - ["2026-03-08T00:10:00.000Z", "2026-03-08T00:10:00.000Z", tracker.id], - ); - - fixture.service.tick({ runId: started.run.id }); - - const refreshed = fixture.service.listSteps(started.run.id).find((step) => step.id === tracker.id); - expect(refreshed?.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("holds downstream steps pending until required validation passes", async () => { - const fixture = await createFixture(); - try { - const implementationPhase = { - id: "phase-implementation", - phaseKey: "implementation", - name: "Implementation", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true, criteria: "Reviewer must confirm the change." }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [implementationPhase] }, - phaseRuntime: { - currentPhaseKey: "implementation", - currentPhaseName: "Implementation", - currentPhaseModel: implementationPhase.model, - }, - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [ - { - stepKey: "impl-auth", - title: "Implement auth flow", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation", - phaseName: "Implementation", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Reviewer must confirm the change.", - evidence: [], - maxRetries: 2 - } - } - }, - { - stepKey: "wire-auth", - title: "Wire auth into app shell", - stepIndex: 1, - laneId: fixture.laneId, - dependencyStepKeys: ["impl-auth"], - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation", - phaseName: "Implementation", - } - } - ] - }); - - const implStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl-auth"); - const downstreamStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "wire-auth"); - if (!implStep || !downstreamStep) throw new Error("Missing steps for validation gate test"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: implStep.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Implemented auth flow.", - outputs: { - filesChanged: ["src/auth.ts"], - }, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - - const pendingBeforePass = fixture.service.listSteps(started.run.id).find((step) => step.id === downstreamStep.id); - expect(pendingBeforePass?.status).toBe("pending"); - expect( - await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "validation_gate_regression" - }) - ).toBe(0); - - const implAfterSuccess = fixture.service.listSteps(started.run.id).find((step) => step.id === implStep.id); - fixture.db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ?`, - [ - JSON.stringify({ - ...(implAfterSuccess?.metadata ?? {}), - validationState: "pass", - validationPassedAt: "2026-03-08T00:05:00.000Z", - }), - "2026-03-08T00:05:00.000Z", - implStep.id - ] - ); - - fixture.service.tick({ runId: started.run.id }); - - const releasedStep = fixture.service.listSteps(started.run.id).find((step) => step.id === downstreamStep.id); - expect(releasedStep?.status).toBe("ready"); - expect( - await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "validation_gate_released" - }) - ).toBe(1); - } finally { - fixture.dispose(); - } - }); - - it("resumeRun unpauses paused runs safely", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "resume-guard", - title: "Resume Guard", - stepIndex: 0 - } - ] - }); - fixture.service.pauseRun({ runId: started.run.id, reason: "manual_pause" }); - const paused = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - expect(paused?.status).toBe("paused"); - - const resumed = fixture.service.resumeRun({ runId: started.run.id }); - expect(resumed.status).toBe("active"); - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 30 }); - expect(timeline.some((entry) => entry.eventType === "run_resumed")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("uses deterministic default context profile", async () => { - const fixture = await createFixture(); - try { - const profile = fixture.service.getContextProfile(); - expect(profile.id).toBe("orchestrator_deterministic_v1"); - expect(profile.docsMode).toBe("digest_refs"); - } finally { - fixture.dispose(); - } - }); - - it("supports deterministic DAG join semantics (all_success, any_success, quorum)", async () => { - const fixture = await createFixture(); - try { - const anyRun = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "a", title: "A", stepIndex: 0 }, - { stepKey: "b", title: "B", stepIndex: 1 }, - { - stepKey: "join-any", - title: "Join Any", - stepIndex: 2, - dependencyStepKeys: ["a", "b"], - joinPolicy: "any_success" - } - ] - }); - const [aAny, bAny, joinAny] = fixture.service.listSteps(anyRun.run.id); - if (!aAny || !bAny || !joinAny) throw new Error("Missing steps for any_success run"); - const aAnyAttempt = await fixture.service.startAttempt({ runId: anyRun.run.id, stepId: aAny.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ - attemptId: aAnyAttempt.id, - status: "failed", - errorClass: "deterministic", - errorMessage: "deterministic failure" - }); - const bAnyAttempt = await fixture.service.startAttempt({ runId: anyRun.run.id, stepId: bAny.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ attemptId: bAnyAttempt.id, status: "succeeded" }); - const joinAnyStep = fixture.service.listSteps(anyRun.run.id).find((step) => step.id === joinAny.id); - expect(joinAnyStep?.status).toBe("ready"); - - const allRun = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "a", title: "A", stepIndex: 0 }, - { stepKey: "b", title: "B", stepIndex: 1 }, - { - stepKey: "join-all", - title: "Join All", - stepIndex: 2, - dependencyStepKeys: ["a", "b"], - joinPolicy: "all_success" - } - ] - }); - const [aAll, bAll, joinAll] = fixture.service.listSteps(allRun.run.id); - if (!aAll || !bAll || !joinAll) throw new Error("Missing steps for all_success run"); - const aAllAttempt = await fixture.service.startAttempt({ runId: allRun.run.id, stepId: aAll.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ - attemptId: aAllAttempt.id, - status: "failed", - errorClass: "deterministic", - errorMessage: "deterministic failure" - }); - const bAllAttempt = await fixture.service.startAttempt({ runId: allRun.run.id, stepId: bAll.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ attemptId: bAllAttempt.id, status: "succeeded" }); - const joinAllStep = fixture.service.listSteps(allRun.run.id).find((step) => step.id === joinAll.id); - expect(joinAllStep?.status).toBe("blocked"); - - const quorumRun = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "a", title: "A", stepIndex: 0 }, - { stepKey: "b", title: "B", stepIndex: 1 }, - { stepKey: "c", title: "C", stepIndex: 2 }, - { - stepKey: "join-quorum", - title: "Join Quorum", - stepIndex: 3, - dependencyStepKeys: ["a", "b", "c"], - joinPolicy: "quorum", - quorumCount: 2 - } - ] - }); - const [aQ, bQ, cQ, joinQ] = fixture.service.listSteps(quorumRun.run.id); - if (!aQ || !bQ || !cQ || !joinQ) throw new Error("Missing steps for quorum run"); - const aQAttempt = await fixture.service.startAttempt({ runId: quorumRun.run.id, stepId: aQ.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ attemptId: aQAttempt.id, status: "succeeded" }); - const bQAttempt = await fixture.service.startAttempt({ runId: quorumRun.run.id, stepId: bQ.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ attemptId: bQAttempt.id, status: "succeeded" }); - const cQAttempt = await fixture.service.startAttempt({ runId: quorumRun.run.id, stepId: cQ.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ - attemptId: cQAttempt.id, - status: "failed", - errorClass: "deterministic", - errorMessage: "deterministic failure" - }); - const joinQuorumStep = fixture.service.listSteps(quorumRun.run.id).find((step) => step.id === joinQ.id); - expect(joinQuorumStep?.status).toBe("ready"); - } finally { - fixture.dispose(); - } - }); - - it("enforces fan-out/fan-in ordering before a final manual review gate", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "api", - title: "Implement API slice", - stepIndex: 0, - executorKind: "manual" - }, - { - stepKey: "ui", - title: "Implement UI slice", - stepIndex: 1, - executorKind: "manual" - }, - { - stepKey: "integrate", - title: "Integrate outputs", - stepIndex: 2, - dependencyStepKeys: ["api", "ui"], - joinPolicy: "all_success", - executorKind: "manual" - }, - { - stepKey: "final-review", - title: "Final human review", - stepIndex: 3, - dependencyStepKeys: ["integrate"], - executorKind: "manual" - } - ] - }); - - let [apiStep, uiStep, integrateStep, finalReviewStep] = fixture.service.listSteps(started.run.id); - if (!apiStep || !uiStep || !integrateStep || !finalReviewStep) throw new Error("Missing expected steps"); - expect(apiStep.status).toBe("ready"); - expect(uiStep.status).toBe("ready"); - expect(["pending", "blocked"]).toContain(integrateStep.status); - expect(["pending", "blocked"]).toContain(finalReviewStep.status); - - const apiAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: apiStep.id, - ownerId: "owner-api", - executorKind: "manual" - }); - await fixture.service.completeAttempt({ attemptId: apiAttempt.id, status: "succeeded" }); - - [apiStep, uiStep, integrateStep, finalReviewStep] = fixture.service.listSteps(started.run.id); - expect(apiStep.status).toBe("succeeded"); - expect(uiStep.status).toBe("ready"); - expect(["pending", "blocked"]).toContain(integrateStep.status); - expect(["pending", "blocked"]).toContain(finalReviewStep.status); - - const uiAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: uiStep.id, - ownerId: "owner-ui", - executorKind: "manual" - }); - await fixture.service.completeAttempt({ attemptId: uiAttempt.id, status: "succeeded" }); - - [apiStep, uiStep, integrateStep, finalReviewStep] = fixture.service.listSteps(started.run.id); - expect(integrateStep.status).toBe("ready"); - expect(["pending", "blocked"]).toContain(finalReviewStep.status); - - const integrateAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: integrateStep.id, - ownerId: "owner-integrate", - executorKind: "manual" - }); - await fixture.service.completeAttempt({ attemptId: integrateAttempt.id, status: "succeeded" }); - - [apiStep, uiStep, integrateStep, finalReviewStep] = fixture.service.listSteps(started.run.id); - expect(integrateStep.status).toBe("succeeded"); - expect(finalReviewStep.status).toBe("ready"); - - const reviewAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: finalReviewStep.id, - ownerId: "owner-review", - executorKind: "manual" - }); - await fixture.service.completeAttempt({ attemptId: reviewAttempt.id, status: "succeeded" }); - - fixture.service.finalizeRun({ runId: started.run.id, force: true }); - const run = fixture.service.listRuns({ missionId: fixture.missionId }).find((entry) => entry.id === started.run.id); - expect(run?.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("uses configured autopilot parallel cap when AI cap metadata is absent", 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 }); - // Pre-insert terminal_sessions rows for sessions the default adapter will create. - for (let i = 1; i <= 3; i++) { - const sid = `session-${i}`; - 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, ?)`, - [sid, fixture.laneId, now, path.join(transcriptDir, `${sid}.log`), now] - ); - } - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 4 - } - }, - steps: [ - { stepKey: "s1", title: "S1", stepIndex: 0, laneId: fixture.laneId, executorKind: "opencode" }, - { stepKey: "s2", title: "S2", stepIndex: 1, laneId: fixture.laneId, executorKind: "opencode" }, - { stepKey: "s3", title: "S3", stepIndex: 2, laneId: fixture.laneId, executorKind: "opencode" } - ] - }); - - const gateReport = { - id: "gate-fail-1", - generatedAt: new Date().toISOString(), - generatedBy: "deterministic_kernel", - overallStatus: "fail", - gates: [], - notes: ["forced gate fail for test"] - }; - fixture.db.run( - ` - insert into orchestrator_gate_reports( - id, - project_id, - generated_at, - report_json - ) values (?, ?, ?, ?) - `, - [gateReport.id, fixture.projectId, gateReport.generatedAt, JSON.stringify(gateReport)] - ); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "test_dynamic_cap" - }); - expect(startedAttempts).toBe(3); - - const runningAttempts = fixture.service - .listAttempts({ runId: started.run.id }) - .filter((attempt) => attempt.status === "running"); - expect(runningAttempts).toHaveLength(3); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }); - const capEvents = timeline.filter((entry) => entry.eventType === "autopilot_parallelism_cap_adjusted"); - expect(capEvents.length).toBeGreaterThanOrEqual(1); - const allReasons = capEvents.flatMap((evt) => { - const detail = evt.detail as Record<string, unknown> | null; - return Array.isArray(detail?.reasons) - ? (detail!.reasons as unknown[]).map((entry) => String(entry)) - : []; - }); - expect(allReasons).toContain("configured_cap"); - expect(allReasons).not.toContain("gate_fail"); - expect(allReasons).not.toContain("initial_ramp_bypass"); - expect(allReasons).not.toContain("claim_conflicts"); - expect(allReasons).not.toContain("context_pressure"); - expect(allReasons).not.toContain("resource_pressure"); - } finally { - fixture.dispose(); - } - }); - - it("uses AI cap directives without deterministic gate/context/resource reductions", 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 }); - for (let i = 1; i <= 3; i++) { - const sid = `session-${i}`; - 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, ?)`, - [sid, fixture.laneId, now, path.join(transcriptDir, `${sid}.log`), now] - ); - } - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 4 - }, - aiDecisions: { - parallelismCap: 3, - disableHeuristicParallelism: false, - source: "ai_decision_service", - lastDecisionAt: now - } - }, - steps: [ - { stepKey: "s1", title: "S1", stepIndex: 0, laneId: fixture.laneId, executorKind: "opencode" }, - { stepKey: "s2", title: "S2", stepIndex: 1, laneId: fixture.laneId, executorKind: "opencode" }, - { stepKey: "s3", title: "S3", stepIndex: 2, laneId: fixture.laneId, executorKind: "opencode" } - ] - }); - - const gateReport = { - id: "gate-fail-ai-1", - generatedAt: new Date().toISOString(), - generatedBy: "deterministic_kernel", - overallStatus: "fail", - gates: [], - notes: ["forced gate fail for AI cap bypass test"] - }; - fixture.db.run( - ` - insert into orchestrator_gate_reports( - id, - project_id, - generated_at, - report_json - ) values (?, ?, ?, ?) - `, - [gateReport.id, fixture.projectId, gateReport.generatedAt, JSON.stringify(gateReport)] - ); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "test_ai_cap" - }); - expect(startedAttempts).toBe(3); - - const runningAttempts = fixture.service - .listAttempts({ runId: started.run.id }) - .filter((attempt) => attempt.status === "running"); - expect(runningAttempts).toHaveLength(3); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }); - const capEvents = timeline.filter((entry) => entry.eventType === "autopilot_parallelism_cap_adjusted"); - expect(capEvents.length).toBeGreaterThanOrEqual(1); - const allReasons = capEvents.flatMap((evt) => { - const detail = evt.detail as Record<string, unknown> | null; - return Array.isArray(detail?.reasons) - ? (detail!.reasons as unknown[]).map((entry) => String(entry)) - : []; - }); - expect(allReasons).toContain("ai_decision_cap"); - expect(allReasons).not.toContain("gate_fail"); - expect(allReasons).not.toContain("initial_ramp_bypass"); - expect(allReasons).not.toContain("claim_conflicts"); - expect(allReasons).not.toContain("context_pressure"); - expect(allReasons).not.toContain("resource_pressure"); - } finally { - fixture.dispose(); - } - }); - - it("prioritizes aiPriority when selecting ready steps under constrained autopilot cap", 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 }); - 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, ?)`, - ["session-1", fixture.laneId, now, path.join(transcriptDir, "session-1.log"), now] - ); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [ - { - stepKey: "low-priority", - title: "Low Priority", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { aiPriority: 1 } - }, - { - stepKey: "high-priority", - title: "High Priority", - stepIndex: 1, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { aiPriority: 50 } - }, - { - stepKey: "no-ai-priority", - title: "No AI Priority", - stepIndex: 2, - laneId: fixture.laneId, - executorKind: "opencode" - } - ] - }); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "test_ai_priority_ordering" - }); - expect(startedAttempts).toBe(1); - - const runningAttempt = fixture.service - .listAttempts({ runId: started.run.id }) - .find((attempt) => attempt.status === "running"); - expect(runningAttempt).toBeTruthy(); - const highPriorityStep = fixture.service - .listSteps(started.run.id) - .find((step) => step.stepKey === "high-priority"); - expect(highPriorityStep).toBeTruthy(); - expect(runningAttempt?.stepId).toBe(highPriorityStep?.id); - } finally { - fixture.dispose(); - } - }); - - it("rejects invalid step graphs at run start (unknown dependencies + cycles)", async () => { - const fixture = await createFixture(); - try { - expect(() => - fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "compile", - title: "Compile", - stepIndex: 0 - }, - { - stepKey: "verify", - title: "Verify", - stepIndex: 1, - dependencyStepKeys: ["missing_step"] - } - ] - }) - ).toThrow(/unknown dependency/i); - expect(fixture.service.listRuns({ missionId: fixture.missionId })).toHaveLength(0); - - expect(() => - fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "a", - title: "A", - stepIndex: 0, - dependencyStepKeys: ["b"] - }, - { - stepKey: "b", - title: "B", - stepIndex: 1, - dependencyStepKeys: ["a"] - } - ] - }) - ).toThrow(/dependency cycle/i); - expect(fixture.service.listRuns({ missionId: fixture.missionId })).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("maps mission planner metadata into deterministic run graph and autopilot metadata", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - maxParallelWorkers: 2 - } - } - } - }) - } - }); - try { - const now = "2026-02-19T00:00:00.000Z"; - fixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-1', ?, ?, 0, 'Branch A', null, 'implementation', ?, 'pending', '{"stepType":"implementation"}', ?, ?, null, null), - ('mstep-2', ?, ?, 1, 'Branch B', null, 'implementation', ?, 'pending', '{"stepType":"implementation"}', ?, ?, null, null), - ('mstep-3', ?, ?, 2, 'Join', null, 'integration', ?, 'pending', '{"stepType":"integration","dependencyIndices":[0,1],"joinPolicy":"quorum","quorumCount":1}', ?, ?, null, null) - `, - [ - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now, - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now, - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now - ] - ); - - const started = fixture.service.startRunFromMission({ - missionId: fixture.missionId, - runMode: "autopilot", - defaultExecutorKind: "opencode", - metadata: { - plannerParallelismCap: 6 - } - }); - - const run = fixture.service.listRuns({ missionId: fixture.missionId })[0]; - expect(run?.metadata?.runMode).toBe("autopilot"); - const autopilot = run?.metadata?.autopilot as Record<string, unknown> | undefined; - expect(autopilot?.enabled).toBe(true); - expect(autopilot?.executorKind).toBe("opencode"); - expect(autopilot?.parallelismCap).toBe(2); - const planner = run?.metadata?.planner as Record<string, unknown> | undefined; - expect(planner?.parallelismCap).toBe(2); - - const steps = fixture.service.listSteps(started.run.id); - const join = steps.find((step) => step.missionStepId === "mstep-3"); - expect(join?.joinPolicy).toBe("quorum"); - expect(join?.quorumCount).toBe(1); - expect(join?.dependencyStepIds.length).toBe(2); - } finally { - fixture.dispose(); - } - }); - - it("applies teammatePlanMode when deriving per-step plan approval metadata", async () => { - const requiredFixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - teammatePlanMode: "required" - } - } - } - }) - } - }); - try { - const now = "2026-02-22T00:00:00.000Z"; - requiredFixture.db.run(`delete from mission_steps where mission_id = ?`, [requiredFixture.missionId]); - requiredFixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-plan-1', ?, ?, 0, 'Implement feature', null, 'implementation', ?, 'pending', '{"stepKey":"impl-1","stepType":"implementation"}', ?, ?, null, null), - ('mstep-plan-2', ?, ?, 1, 'Explicit no-plan', null, 'implementation', ?, 'pending', '{"stepKey":"impl-2","stepType":"implementation","requiresPlanApproval":false}', ?, ?, null, null) - `, - [ - requiredFixture.missionId, - requiredFixture.projectId, - requiredFixture.laneId, - now, - now, - requiredFixture.missionId, - requiredFixture.projectId, - requiredFixture.laneId, - now, - now - ] - ); - - const requiredStarted = requiredFixture.service.startRunFromMission({ - missionId: requiredFixture.missionId, - runMode: "autopilot", - defaultExecutorKind: "opencode" - }); - const requiredSteps = requiredFixture.service.listSteps(requiredStarted.run.id); - const inferredStep = requiredSteps.find((step) => step.missionStepId === "mstep-plan-1"); - const explicitStep = requiredSteps.find((step) => step.missionStepId === "mstep-plan-2"); - expect(inferredStep?.metadata?.requiresPlanApproval).toBe(true); - expect(inferredStep?.metadata?.teammatePlanMode).toBe("required"); - expect(explicitStep?.metadata?.requiresPlanApproval).toBe(false); - } finally { - requiredFixture.dispose(); - } - - const offFixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - teammatePlanMode: "off" - } - } - } - }) - } - }); - try { - const now = "2026-02-22T00:00:00.000Z"; - offFixture.db.run(`delete from mission_steps where mission_id = ?`, [offFixture.missionId]); - offFixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-plan-3', ?, ?, 0, 'Analyze requirements', null, 'analysis', ?, 'pending', '{"stepKey":"analysis-1","stepType":"analysis"}', ?, ?, null, null), - ('mstep-plan-4', ?, ?, 1, 'Explicit plan', null, 'analysis', ?, 'pending', '{"stepKey":"analysis-2","stepType":"analysis","requiresPlanApproval":true}', ?, ?, null, null) - `, - [ - offFixture.missionId, - offFixture.projectId, - offFixture.laneId, - now, - now, - offFixture.missionId, - offFixture.projectId, - offFixture.laneId, - now, - now - ] - ); - - const offStarted = offFixture.service.startRunFromMission({ - missionId: offFixture.missionId, - runMode: "autopilot", - defaultExecutorKind: "opencode" - }); - const offSteps = offFixture.service.listSteps(offStarted.run.id); - const inferredAnalysis = offSteps.find((step) => step.missionStepId === "mstep-plan-3"); - const explicitAnalysis = offSteps.find((step) => step.missionStepId === "mstep-plan-4"); - expect(inferredAnalysis?.metadata?.requiresPlanApproval).toBe(false); - expect(inferredAnalysis?.metadata?.teammatePlanMode).toBe("off"); - expect(explicitAnalysis?.metadata?.requiresPlanApproval).toBe(true); - } finally { - offFixture.dispose(); - } - }); - - it("preserves mission phase metadata and initializes phase runtime when starting from a mission", async () => { - const fixture = await createFixture(); - try { - const planningPhase = { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work", - instructions: "Research the task first.", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false, criteria: "" }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - const releasePhase = { - id: "phase-release", - phaseKey: "release", - name: "Release", - description: "Ship the change", - instructions: "Prepare release notes and ship.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustFollow: ["planning"] }, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true, criteria: "Release checklist must pass." }, - isBuiltIn: false, - isCustom: true, - position: 1, - createdAt: "2026-03-08T00:00:00.000Z", - updatedAt: "2026-03-08T00:00:00.000Z", - }; - - fixture.db.run( - `update missions set metadata_json = ? where id = ? and project_id = ?`, - [ - JSON.stringify({ - phaseConfiguration: { - selectedPhases: [planningPhase, releasePhase], - profileId: "custom-phase-profile" - }, - missionLevelSettings: { - prStrategy: { kind: "manual" } - }, - phaseOverride: [planningPhase, releasePhase], - phaseProfileId: "custom-phase-profile" - }), - fixture.missionId, - fixture.projectId - ] - ); - - const started = fixture.service.startRunFromMission({ - missionId: fixture.missionId, - runMode: "manual", - defaultExecutorKind: "manual" - }); - const run = fixture.service.listRuns({ missionId: fixture.missionId }).find((entry) => entry.id === started.run.id); - const metadata = run?.metadata as Record<string, unknown> | undefined; - const phaseRuntime = (metadata?.phaseRuntime ?? {}) as Record<string, unknown>; - - expect(metadata?.phaseConfiguration).toEqual({ - selectedPhases: [planningPhase, releasePhase], - profileId: "custom-phase-profile" - }); - expect(metadata?.missionLevelSettings).toEqual({ - prStrategy: { kind: "manual" } - }); - expect(metadata?.phaseOverride).toEqual([planningPhase, releasePhase]); - expect(metadata?.phaseProfileId).toBe("custom-phase-profile"); - expect(phaseRuntime.currentPhaseKey).toBe("planning"); - expect(phaseRuntime.currentPhaseName).toBe("Planning"); - } finally { - fixture.dispose(); - } - }); - - it("preserves explicit empty mission-step dependencies instead of forcing sequential fallback", async () => { - const fixture = await createFixture(); - try { - const now = "2026-02-19T00:00:00.000Z"; - fixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-empty-1', ?, ?, 0, 'Implement API', 'Add endpoint', 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-empty-2', ?, ?, 1, 'Update docs', 'Update README', 'docs', ?, 'pending', '{"stepType":"docs","dependencyStepKeys":[]}', ?, ?, null, null) - `, - [ - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now, - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now - ] - ); - - const started = fixture.service.startRunFromMission({ - missionId: fixture.missionId, - runMode: "manual", - defaultExecutorKind: "manual" - }); - - const steps = fixture.service.listSteps(started.run.id); - const docsStep = steps.find((step) => step.missionStepId === "mstep-empty-2"); - expect(docsStep).toBeTruthy(); - expect(docsStep?.dependencyStepIds).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("derives integration lane metadata from dependency lanes when missing", async () => { - const fixture = await createFixture(); - try { - const now = "2026-02-21T00:00:00.000Z"; - fixture.db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values - (?, ?, 'Child A', null, 'worktree', 'main', 'feature/child-a', ?, null, 0, ?, null, null, null, 'active', ?, null), - (?, ?, 'Child B', null, 'worktree', 'main', 'feature/child-b', ?, null, 0, ?, null, null, null, 'active', ?, null) - `, - [ - "lane-child-a", - fixture.projectId, - fixture.projectRoot, - fixture.laneId, - now, - "lane-child-b", - fixture.projectId, - fixture.projectRoot, - fixture.laneId, - now - ] - ); - - fixture.db.run(`delete from mission_steps where mission_id = ?`, [fixture.missionId]); - fixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values - ('mstep-int-1', ?, ?, 0, 'Root A', 'A', 'implementation', ?, 'pending', '{"stepKey":"root-a","stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-int-2', ?, ?, 1, 'Root B', 'B', 'implementation', ?, 'pending', '{"stepKey":"root-b","stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-int-3', ?, ?, 2, 'Root C', 'C', 'implementation', ?, 'pending', '{"stepKey":"root-c","stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-int-4', ?, ?, 3, 'Integrate', 'join', 'integration', ?, 'pending', '{"stepKey":"join","stepType":"integration","dependencyStepKeys":["root-a","root-b","root-c"]}', ?, ?, null, null) - `, - [ - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now, - fixture.missionId, - fixture.projectId, - "lane-child-a", - now, - now, - fixture.missionId, - fixture.projectId, - "lane-child-b", - now, - now, - fixture.missionId, - fixture.projectId, - fixture.laneId, - now, - now - ] - ); - - const started = fixture.service.startRunFromMission({ - missionId: fixture.missionId, - runMode: "manual", - defaultExecutorKind: "manual" - }); - - const steps = fixture.service.listSteps(started.run.id); - const join = steps.find((step) => step.missionStepId === "mstep-int-4"); - expect(join).toBeTruthy(); - expect(join?.metadata?.targetLaneId).toBe(fixture.laneId); - const sourceLaneIds = Array.isArray(join?.metadata?.sourceLaneIds) ? join?.metadata?.sourceLaneIds : []; - expect(sourceLaneIds).toContain("lane-child-a"); - expect(sourceLaneIds).toContain("lane-child-b"); - } finally { - fixture.dispose(); - } - }); - - it("keeps policy-blocked steps blocked until explicit intervention", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "blocked_policy", - title: "Blocked Policy", - stepIndex: 0 - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "blocked", - errorClass: "policy", - errorMessage: "Blocked by missing integration metadata." - }); - - fixture.service.tick({ runId: started.run.id }); - const refreshed = fixture.service.listSteps(started.run.id).find((entry) => entry.id === step.id); - expect(refreshed?.status).toBe("blocked"); - expect(refreshed?.metadata?.blockedSticky).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("propagates sticky policy blocks to downstream dependencies and pauses the run", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "integration_gate", - title: "Integration Gate", - stepIndex: 0 - }, - { - stepKey: "downstream_validation", - title: "Downstream Validation", - stepIndex: 1, - dependencyStepKeys: ["integration_gate"] - } - ] - }); - const [integrationStep, downstreamStep] = fixture.service.listSteps(started.run.id); - if (!integrationStep || !downstreamStep) throw new Error("Expected two orchestrator steps"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: integrationStep.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "blocked", - errorClass: "policy", - errorMessage: "Manual intervention required." - }); - - fixture.service.pauseRun({ runId: started.run.id, reason: "policy_block" }); - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const refreshedIntegration = graph.steps.find((step) => step.id === integrationStep.id); - const refreshedDownstream = graph.steps.find((step) => step.id === downstreamStep.id); - expect(refreshedIntegration?.status).toBe("blocked"); - expect(refreshedDownstream?.status).toBe("blocked"); - expect(graph.run.status).toBe("paused"); - } finally { - fixture.dispose(); - } - }); - - it("keeps retry backoff neutral when AI retry metadata is absent", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "retryable", title: "Retryable", stepIndex: 0, retryLimit: 2 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - // Explicit caller backoff of 0 overrides exponential default - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "transient", - errorMessage: "transient failure", - retryBackoffMs: 0 - }); - - const afterFailure = fixture.service.listSteps(started.run.id)[0]; - expect(["pending", "ready"]).toContain(afterFailure?.status); - expect(Number((afterFailure?.metadata?.lastRetryBackoffMs as number | undefined) ?? -1)).toBe(0); - fixture.service.tick({ runId: started.run.id }); - const retryReady = fixture.service.listSteps(started.run.id)[0]; - expect(retryReady?.status).toBe("ready"); - } finally { - fixture.dispose(); - } - }); - - it("uses exponential backoff when no explicit backoff is provided", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "retry-default-exp", title: "Retry Exponential", stepIndex: 0, retryLimit: 1 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - // No retryBackoffMs or aiRetryBackoffMs — should use exponential default (10s base) - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "transient", - errorMessage: "transient failure" - }); - - const afterFailure = fixture.service.listSteps(started.run.id)[0]; - expect(afterFailure?.status).toBe("pending"); - // Exponential backoff: 10_000 * 2^0 = 10_000 for first retry - expect(Number((afterFailure?.metadata?.lastRetryBackoffMs as number | undefined) ?? -1)).toBe(10_000); - } finally { - fixture.dispose(); - } - }); - - it("uses aiRetryBackoffMs metadata for retry scheduling when no caller backoff", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "retryable-ai-backoff", - title: "Retryable AI Backoff", - stepIndex: 0, - retryLimit: 2, - metadata: { - aiRetryBackoffMs: 42_000 - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - // No caller retryBackoffMs — AI metadata (42s) should take precedence over exponential default - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "transient", - errorMessage: "temporary outage" - }); - - const afterFailure = fixture.service.listSteps(started.run.id)[0]; - expect(afterFailure?.status).toBe("pending"); - expect(Number((afterFailure?.metadata?.lastRetryBackoffMs as number | undefined) ?? 0)).toBe(42_000); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const recordedAttempt = graph.attempts.find((entry) => entry.id === attempt.id); - expect(recordedAttempt?.retryBackoffMs).toBe(42_000); - } finally { - fixture.dispose(); - } - }); - - it("recovers from retryable failure and succeeds on retry", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "retry-once", title: "Retry Once", stepIndex: 0, retryLimit: 1 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const first = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: first.id, - status: "failed", - errorClass: "transient", - errorMessage: "temporary outage", - retryBackoffMs: 0 - }); - - fixture.service.tick({ runId: started.run.id }); - const readyAgain = fixture.service.listSteps(started.run.id)[0]; - expect(readyAgain?.status).toBe("ready"); - expect(readyAgain?.retryCount).toBe(1); - - const second = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: second.id, - status: "succeeded" - }); - - fixture.service.finalizeRun({ runId: started.run.id, force: true }); - const finalGraph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const finalStep = finalGraph.steps.find((entry) => entry.id === step.id); - expect(finalStep?.status).toBe("succeeded"); - expect(finalGraph.run.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("uses phaseOverride metadata to avoid inventing a disabled testing phase in completion evaluation", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseOverride: [ - { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }, - { - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Validate", - instructions: "", - model: { provider: "anthropic", modelId: "anthropic/claude-sonnet-4-6" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }, - ], - }, - steps: [ - { - stepKey: "implement", - title: "Implement", - stepIndex: 0, - metadata: { - stepType: "implementation", - phaseKey: "development", - phaseName: "Development", - }, - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded" - }); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - expect(graph.completionEvaluation?.riskFactors).not.toContain("testing_required_but_missing"); - expect( - graph.completionEvaluation?.diagnostics.some((entry) => entry.message.includes('Required phase "testing"')) - ).toBe(false); - } finally { - fixture.dispose(); - } - }); - - it("marks step failed after retry exhaustion", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "exhaust", title: "Exhaust Retries", stepIndex: 0, retryLimit: 1 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const first = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: first.id, - status: "failed", - errorClass: "transient", - errorMessage: "attempt one", - retryBackoffMs: 0 - }); - fixture.service.tick({ runId: started.run.id }); - - const second = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: second.id, - status: "failed", - errorClass: "transient", - errorMessage: "attempt two" - }); - - fixture.service.finalizeRun({ runId: started.run.id, force: true }); - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 0 }); - const finalStep = graph.steps.find((entry) => entry.id === step.id); - expect(finalStep?.status).toBe("failed"); - expect(finalStep?.retryCount).toBe(1); - expect(graph.run.status).toBe("failed"); - } finally { - fixture.dispose(); - } - }); - - it("matches null step ids when resolving run-level interventions during finalize", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "finalize-step", title: "Finalize Step", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded" - }); - - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: null, - eventType: "intervention_opened", - eventKey: "run-level-intervention" - }); - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: null, - eventType: "intervention_resolved", - eventKey: "run-level-intervention-resolved" - }); - - const finalized = fixture.service.finalizeRun({ runId: started.run.id }); - expect(finalized.finalized).toBe(true); - expect(finalized.blockers).toHaveLength(0); - expect(finalized.finalStatus).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("does not let force finalize bypass required phase success", async () => { - const fixture = await createFixture(); - try { - const developmentPhase = { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the feature.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true, criteria: "Implementation must actually succeed" }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }; - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [developmentPhase] }, - missionLevelSettings: { prStrategy: { kind: "manual" } }, - }, - steps: [ - { - stepKey: "impl", - title: "Implementation", - stepIndex: 0, - metadata: { - stepType: "implementation", - phaseKey: "development", - phaseName: "Development", - }, - }, - ], - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - fixture.service.skipStep({ - runId: started.run.id, - stepId: step.id, - reason: "Skipped by coordinator", - }); - - const finalized = fixture.service.finalizeRun({ runId: started.run.id, force: true }); - const run = fixture.service.listRuns({ missionId: fixture.missionId }).find((entry) => entry.id === started.run.id); - - expect(finalized.finalized).toBe(false); - expect(finalized.finalStatus).toBe("active"); - expect(finalized.blockers.some((entry) => entry.includes("without any successful work"))).toBe(true); - expect(run?.status).toBe("active"); - } finally { - fixture.dispose(); - } - }); - - it("reactivates a completion-blocked run when recovery steps are added", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [], - }); - fixture.db.run( - `update orchestrator_runs set status = 'completing', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - - const [recoveryStep] = fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "recovery", - title: "Recovery validation work", - stepIndex: 1, - }, - ], - }); - expect(recoveryStep?.status).toBe("ready"); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - expect(graph.run.status).toBe("active"); - expect(graph.timeline.some((entry) => entry.eventType === "run_reactivated")).toBe(true); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: recoveryStep!.id, - ownerId: "owner", - }); - expect(attempt.status).toBe("running"); - } finally { - fixture.dispose(); - } - }); - - it("moves blocked finalization back to active when pending recovery work exists", async () => { - const fixture = await createFixture(); - try { - const developmentPhase = { - id: "phase-development", - phaseKey: "development", - name: "Development", - description: "Build the feature.", - instructions: "Implement the feature.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }; - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [developmentPhase] }, - missionLevelSettings: { prStrategy: { kind: "manual" } }, - }, - steps: [ - { - stepKey: "pending-recovery", - title: "Pending recovery", - stepIndex: 0, - metadata: { - stepType: "implementation", - phaseKey: "development", - phaseName: "Development", - }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'completing', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - - const finalized = fixture.service.finalizeRun({ runId: started.run.id }); - expect(finalized.finalized).toBe(false); - expect(finalized.finalStatus).toBe("active"); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - expect(graph.run.status).toBe("active"); - expect( - graph.timeline.some((entry) => ( - entry.eventType === "run_completion_blocked" - && (entry.detail as Record<string, unknown> | null)?.nextStatus === "active" - )), - ).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("lets a dedicated validation phase run the previous phase gate", async () => { - const fixture = await createFixture(); - try { - const testingPhase = { - id: "phase-testing", - phaseKey: "testing", - name: "Testing", - description: "Run tests.", - instructions: "Run tests.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: { mustBeFirst: false, mustBeLast: false, mustFollow: [], mustPrecede: [], canLoop: false, loopTarget: null }, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true }, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }; - const validationPhase = { - ...testingPhase, - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - description: "Validate output.", - instructions: "Validate output.", - validationGate: { tier: "dedicated", required: true }, - position: 1, - }; - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { phases: [testingPhase, validationPhase] }, - missionLevelSettings: { prStrategy: { kind: "manual" } }, - }, - steps: [ - { - stepKey: "test-work", - title: "Test work", - stepIndex: 0, - metadata: { - phaseKey: "testing", - phaseName: "Testing", - stepType: "testing", - }, - }, - ], - }); - const testStep = fixture.service.listSteps(started.run.id)[0]; - if (!testStep) throw new Error("Missing test step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: testStep.id, - ownerId: "owner", - }); - await fixture.service.completeAttempt({ attemptId: attempt.id, status: "succeeded" }); - - const [validationStep] = fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "validate-test-work", - title: "Validate test work", - stepIndex: 1, - dependencyStepKeys: ["test-work"], - metadata: { - phaseKey: "validation", - phaseName: "Validation", - stepType: "validation", - }, - }, - ], - }); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - const phaseRuntime = graph.run.metadata?.phaseRuntime as Record<string, unknown> | undefined; - expect(phaseRuntime?.currentPhaseKey).toBe("validation"); - expect(graph.steps.find((step) => step.id === validationStep?.id)?.status).toBe("ready"); - } finally { - fixture.dispose(); - } - }); - - it("keeps interventions on the same step distinct until each interventionId is resolved", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "finalize-step", title: "Finalize Step", stepIndex: 0 }], - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner", - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: step.id, - eventType: "intervention_opened", - eventKey: "intervention-opened-1", - payload: { interventionId: "intervention-1" }, - }); - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: step.id, - eventType: "intervention_opened", - eventKey: "intervention-opened-2", - payload: { interventionId: "intervention-2" }, - }); - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: step.id, - eventType: "intervention_resolved", - eventKey: "intervention-resolved-1", - payload: { interventionId: "intervention-1" }, - }); - - const blocked = fixture.service.finalizeRun({ runId: started.run.id }); - expect(blocked.finalized).toBe(false); - expect(blocked.blockers).toEqual( - expect.arrayContaining([expect.stringContaining("unresolved intervention")]), - ); - - fixture.service.appendRuntimeEvent({ - runId: started.run.id, - stepId: step.id, - eventType: "intervention_resolved", - eventKey: "intervention-resolved-2", - payload: { interventionId: "intervention-2" }, - }); - - const finalized = fixture.service.finalizeRun({ runId: started.run.id }); - expect(finalized.finalized).toBe(true); - expect(finalized.finalStatus).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("supports claim heartbeat and expiry recovery for blocked collision steps", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "one", - title: "One", - stepIndex: 0, - policy: { - claimScopes: [{ scopeKind: "lane", scopeValue: `lane:${fixture.laneId}`, ttlMs: 60_000 }] - } - }, - { - stepKey: "two", - title: "Two", - stepIndex: 1, - policy: { - claimScopes: [{ scopeKind: "lane", scopeValue: `lane:${fixture.laneId}`, ttlMs: 60_000 }] - } - } - ] - }); - const [one, two] = fixture.service.listSteps(started.run.id); - if (!one || !two) throw new Error("Missing steps"); - - const firstAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: one.id, - ownerId: "owner-a" - }); - const beats = fixture.service.heartbeatClaims({ attemptId: firstAttempt.id, ownerId: "owner-a" }); - expect(beats).toBe(1); - - const blockedAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: two.id, - ownerId: "owner-b" - }); - expect(blockedAttempt.status).toBe("blocked"); - - fixture.db.run( - ` - update orchestrator_claims - set expires_at = ? - where attempt_id = ? - `, - ["2000-01-01T00:00:00.000Z", firstAttempt.id] - ); - fixture.service.tick({ runId: started.run.id }); - const recoveredStep = fixture.service.listSteps(started.run.id).find((step) => step.id === two.id); - expect(recoveredStep?.status).toBe("ready"); - } finally { - fixture.dispose(); - } - }); - - it("reconciles tracked session exits and auto-advances autopilot", 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 }); - // Pre-insert terminal_sessions rows for sessions the default adapter will create. - // session-1 for the first step, session-2 for the second (auto-advanced). - for (let i = 1; i <= 2; i++) { - const sid = `session-${i}`; - 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, ?)`, - [sid, fixture.laneId, now, path.join(transcriptDir, `${sid}.log`), now] - ); - } - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "orchestrator-autopilot" - } - }, - steps: [ - { - stepKey: "first", - title: "First", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode" - }, - { - stepKey: "second", - title: "Second", - stepIndex: 1, - dependencyStepKeys: ["first"], - laneId: fixture.laneId, - executorKind: "opencode" - } - ] - }); - - const firstStepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!firstStepId) throw new Error("Expected first step"); - const firstAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: firstStepId, - ownerId: "operator" - }); - expect(firstAttempt?.status).toBe("running"); - expect(firstAttempt?.executorSessionId).toBeTruthy(); - if (!firstAttempt?.executorSessionId) throw new Error("Expected running session-backed attempt"); - const transcriptPath = path.join(transcriptDir, `${firstAttempt.executorSessionId}.log`); - fs.writeFileSync( - transcriptPath, - "Implemented the first step and verified the result before exiting.\n", - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: firstAttempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const after = fixture.service.listAttempts({ runId: started.run.id }); - const firstAfter = after.find((attempt) => attempt.id === firstAttempt.id); - expect(firstAfter?.status).toBe("succeeded"); - - const secondStepId = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "second")?.id; - const secondAttempt = after.find((attempt) => attempt.stepId === secondStepId); - expect(secondAttempt?.status).toBe("running"); - } finally { - fixture.dispose(); - } - }); - - it("hydrates tracked-session success summaries from transcript tails when no explicit result was reported", 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" - } - ] - }); - 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" - }); - fixture.db.run( - ` - update orchestrator_attempts - set status = 'running', - executor_session_id = ?, - executor_kind = 'codex', - error_class = 'none', - error_message = null, - completed_at = null - where id = ? - `, - [preSessionId, attempt.id] - ); - - fs.writeFileSync( - transcriptPath, - "looking at router wiring first\n\nThe plan is ready. The implementation requires 4 targeted changes across 3 existing files plus 1 new file.\n", - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: preSessionId, - 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?.resultEnvelope?.summary).toContain("The plan is ready."); - } finally { - fixture.dispose(); - } - }); - - it("recovers markdown report_result payloads from tracked worker transcripts", 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, - metadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-implementation-math", - phaseKey: "implementation_math", - name: "Implementation math", - position: 1, - instructions: "Implement math work.", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true, criteria: "Tests must pass." }, - isBuiltIn: false, - isCustom: true, - createdAt: now, - updatedAt: now, - }, - ], - }, - phaseRuntime: { currentPhaseKey: "implementation_math", currentPhaseName: "Implementation math" }, - }, - steps: [ - { - stepKey: "implementation-math-worker", - title: "Implementation math worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation_math", - phaseName: "Implementation math", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Tests must pass.", - evidence: [], - maxRetries: 2, - }, - }, - } - ] - }); - 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" - }); - fixture.db.run( - ` - update orchestrator_attempts - set status = 'running', - executor_session_id = ?, - executor_kind = 'codex', - error_class = 'none', - error_message = null, - completed_at = null - where id = ? - `, - [preSessionId, attempt.id] - ); - - fs.writeFileSync( - transcriptPath, - [ - "Implemented helpers and ran the package tests.", - "", - "## report_result", - "### outcome", - "completed", - "### summary", - "Added three math utilities and tests.", - "### filesChanged", - "- [src/math.js](file:///tmp/src/math.js)", - "- [test/math.test.js](file:///tmp/test/math.test.js)", - "### testsRun", - "- Ran `npm test`", - "- Passed: 9", - "- Failed: 0", - "- Skipped: 0", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: preSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toBe("Added three math utilities and tests."); - expect(metadata.lastResultReport?.filesChanged).toEqual(["src/math.js", "test/math.test.js"]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 9, - failed: 0, - skipped: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("blocks ask-enabled planners on recovered natural-language questions instead of failing the plan contract", 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-planner-question"; - 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, 'Planning 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, - metadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Ask one clarification before planning.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, maxQuestions: 1 }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: true, - createdAt: now, - updatedAt: now, - }, - ], - }, - phaseRuntime: { currentPhaseKey: "planning", currentPhaseName: "Planning" }, - }, - steps: [ - { - stepKey: "planning-worker", - title: "Planning worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "codex", - metadata: { - stepType: "planning", - phaseKey: "planning", - phaseName: "Planning", - readOnlyExecution: true, - }, - } - ] - }); - 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" - }); - fixture.db.run( - ` - update orchestrator_attempts - set status = 'running', - executor_session_id = ?, - executor_kind = 'codex', - error_class = 'none', - error_message = null, - completed_at = null - where id = ? - `, - [preSessionId, attempt.id] - ); - - fs.writeFileSync( - transcriptPath, - [ - "codex", - "I inspected the parser and formatter files.", - "codex", - "Should missing/unknown item weights default to `1`?", - "tokens used", - "1200", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: preSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - const step = graph.steps.find((entry) => entry.id === stepId); - const completedAttempt = graph.attempts.find((entry) => entry.id === attempt.id); - expect(step?.status).toBe("blocked"); - expect(completedAttempt?.status).toBe("blocked"); - expect(completedAttempt?.errorClass).toBe("deterministic"); - const awaitingUserInput = step?.metadata?.awaitingUserInput as Record<string, unknown> | undefined; - expect(String(awaitingUserInput?.question ?? "")).toContain("default to `1`"); - expect(graph.run.status).toBe("paused"); - const intervention = fixture.db.get<{ status: string; intervention_type: string; body: string; metadata_json: string | null }>( - ` - select status, intervention_type, body, metadata_json - from mission_interventions - where mission_id = ? - limit 1 - `, - [fixture.missionId] - ); - expect(intervention).toEqual(expect.objectContaining({ - status: "open", - intervention_type: "manual_input", - })); - expect(intervention?.body).toContain("default to `1`"); - expect(JSON.parse(intervention?.metadata_json ?? "{}")).toEqual(expect.objectContaining({ - reasonCode: "planner_natural_question", - attemptId: attempt.id, - })); - - const questionEvent = fixture.service - .listRuntimeEvents({ attemptId: attempt.id, eventTypes: ["question"], limit: 5 }) - .find((entry) => entry.eventType === "question"); - expect(questionEvent?.payload).toEqual(expect.objectContaining({ - source: "planner_natural_question", - })); - } finally { - fixture.dispose(); - } - }); - - it("blocks a planning worker that skips a required blocking question before returning a plan", async () => { - const fixture = await createFixture(); - try { - const now = "2026-02-19T00:00:00.000Z"; - fixture.db.run( - `update missions set prompt = ? where id = ?`, - [ - "Build a launch dashboard with a planning phase that asks blocking questions before implementation.", - fixture.missionId, - ], - ); - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Ask one clarification before planning.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, requiredBeforeExit: true }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: true, - createdAt: now, - updatedAt: now, - }, - ], - }, - phaseRuntime: { currentPhaseKey: "planning", currentPhaseName: "Planning" }, - }, - steps: [ - { - stepKey: "planning-worker", - title: "Planning worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "codex", - metadata: { - stepType: "planning", - phaseKey: "planning", - phaseName: "Planning", - phaseAskQuestions: { enabled: true, requiredBeforeExit: true }, - readOnlyExecution: true, - }, - }, - ], - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Expected planning step"); - fixture.service.updateStepMetadata({ - runId: started.run.id, - stepId: step.id, - metadata: { - ...step.metadata, - lastResultReport: { - outcome: "succeeded", - summary: "Planning complete.", - filesChanged: [], - testsRun: { commands: [] }, - plan: { markdown: "## Plan\n- Implement the dashboard." }, - }, - }, - }); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "planner-owner", - executorKind: "codex", - }); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - - expect(completed.status).toBe("blocked"); - expect(completed.errorMessage ?? "").toContain("required blocking question"); - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - expect(graph.steps.find((entry) => entry.id === step.id)?.status).toBe("blocked"); - expect(graph.run.status).toBe("paused"); - const intervention = fixture.db.get<{ status: string; intervention_type: string; metadata_json: string | null }>( - ` - select status, intervention_type, metadata_json - from mission_interventions - where mission_id = ? - limit 1 - `, - [fixture.missionId], - ); - expect(intervention).toEqual(expect.objectContaining({ - status: "open", - intervention_type: "manual_input", - })); - expect(JSON.parse(intervention?.metadata_json ?? "{}")).toEqual(expect.objectContaining({ - reasonCode: "planner_required_question_missing", - attemptId: attempt.id, - })); - } finally { - fixture.dispose(); - } - }); - - it("counts resolved managed chat questions before enforcing required planning clarification", 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 sessionId = "managed-planner-session"; - const transcriptPath = path.join(transcriptDir, `${sessionId}.chat.jsonl`); - fixture.db.run( - `update missions set prompt = ? where id = ?`, - [ - "Build a launch dashboard with a planning phase that asks blocking questions before implementation.", - fixture.missionId, - ], - ); - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - position: 0, - instructions: "Ask one clarification before planning.", - model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, requiredBeforeExit: true }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: true, - createdAt: now, - updatedAt: now, - }, - ], - }, - phaseRuntime: { currentPhaseKey: "planning", currentPhaseName: "Planning" }, - }, - steps: [ - { - stepKey: "planning-worker", - title: "Planning worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "codex", - metadata: { - stepType: "planning", - phaseKey: "planning", - phaseName: "Planning", - phaseAskQuestions: { enabled: true, requiredBeforeExit: true }, - readOnlyExecution: true, - }, - }, - ], - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Expected planning step"); - fixture.service.updateStepMetadata({ - runId: started.run.id, - stepId: step.id, - metadata: { - ...step.metadata, - lastResultReport: { - outcome: "succeeded", - summary: "Planning complete after asking the launch-scope question.", - filesChanged: [], - testsRun: { commands: [] }, - plan: { markdown: "## Plan\n- Implement the dashboard with the approved launch scope." }, - }, - }, - }); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "planner-owner", - executorKind: "codex", - }); - const legacyInterventionId = "legacy-required-planner-question"; - fixture.db.run( - ` - insert into mission_interventions( - id, - mission_id, - project_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - metadata_json, - created_at, - updated_at, - resolved_at - ) values (?, ?, ?, 'manual_input', 'open', null, ?, ?, ?, null, ?, ?, ?, ?, null) - `, - [ - legacyInterventionId, - fixture.missionId, - fixture.projectId, - "Required planner question", - "Which launch scope should the planner assume?", - "Answer the required planning question.", - fixture.laneId, - JSON.stringify({ - source: "planner_required_question_missing", - reasonCode: "planner_required_question_missing", - question: "Which launch scope should the planner assume?", - runId: started.run.id, - stepId: step.id, - stepKey: step.stepKey, - attemptId: attempt.id, - sessionId, - phaseKey: "planning", - stepType: "planning", - }), - now, - now, - ], - ); - - fs.writeFileSync( - transcriptPath, - [ - { - sessionId, - timestamp: now, - sequence: 1, - event: { - type: "approval_request", - itemId: "question-1", - kind: "tool_call", - description: "Which launch scope should the planner assume?", - detail: { - request: { - itemId: "question-1", - source: "codex", - kind: "structured_question", - questions: [ - { - id: "scope", - header: "Scope", - question: "Which launch scope should the planner assume?", - options: [{ label: "Internal drill" }, { label: "Customer launch" }], - }, - ], - }, - }, - }, - }, - { - sessionId, - timestamp: now, - sequence: 2, - event: { - type: "pending_input_resolved", - itemId: "question-1", - resolution: "accepted", - }, - }, - ].map((entry) => JSON.stringify(entry)).join("\n"), - "utf8", - ); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: { trackedSessionId: sessionId, transcriptPath }, - }); - - expect(completed.status).toBe("succeeded"); - const graph = fixture.service.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - expect(graph.steps.find((entry) => entry.id === step.id)?.status).toBe("succeeded"); - const interventions = fixture.db.all<{ id: string; status: string; intervention_type: string; body: string; resolution_kind: string | null; resolution_note: string | null; metadata_json: string | null }>( - ` - select id, status, intervention_type, body, resolution_kind, resolution_note, metadata_json - from mission_interventions - where mission_id = ? - `, - [fixture.missionId], - ); - const intervention = interventions.find((entry) => { - const metadata = JSON.parse(entry.metadata_json ?? "{}"); - return metadata.reasonCode === "planner_chat_question"; - }); - expect(intervention).toEqual(expect.objectContaining({ - status: "resolved", - intervention_type: "manual_input", - })); - expect(intervention?.body).toContain("Which launch scope"); - expect(JSON.parse(intervention?.metadata_json ?? "{}")).toEqual(expect.objectContaining({ - source: "request_user_input", - reasonCode: "planner_chat_question", - attemptId: attempt.id, - itemId: "question-1", - })); - const legacyIntervention = interventions.find((entry) => entry.id === legacyInterventionId); - expect(legacyIntervention).toEqual(expect.objectContaining({ - status: "resolved", - resolution_kind: "answer_provided", - resolution_note: "Answered in ADE chat.", - })); - } finally { - fixture.dispose(); - } - }); - - it("promotes generated planning worker metadata when the mission prompt requires questions", async () => { - const fixture = await createFixture(); - try { - const now = "2026-02-19T00:00:00.000Z"; - fixture.db.run( - `update missions set prompt = ? where id = ?`, - [ - "Planning is blocked until the planner asks required clarification questions before implementation.", - fixture.missionId, - ], - ); - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { - selectedPhases: [ - { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan first", - position: 0, - instructions: "Research, clarify requirements, and design the execution DAG.", - model: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - createdAt: now, - updatedAt: now, - }, - ], - }, - phaseRuntime: { currentPhaseKey: "planning", currentPhaseName: "Planning" }, - }, - steps: [], - }); - - const [step] = fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "planning-worker", - title: "Planning worker", - stepIndex: 0, - executorKind: "codex", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - phaseInstructions: "Investigate dependencies before implementation.", - phaseAskQuestions: { enabled: true, maxQuestions: null, requiredBeforeExit: false }, - stepType: "planning", - taskType: "planning", - readOnlyExecution: true, - instructions: "Ask the required clarification questions through ADE before returning a plan.", - }, - }, - ], - }); - - expect(step?.metadata?.phaseAskQuestions).toEqual(expect.objectContaining({ - enabled: true, - requiredBeforeExit: true, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex final-answer markdown result summaries from tracked worker transcripts", 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-math-worker", - title: "Implementation math worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation_math", - phaseName: "Implementation math", - }, - } - ] - }); - 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.writeFileSync( - transcriptPath, - [ - "codex", - "The math worker lane is complete.", - "", - "Outcome: Math stream completed in the result lane.", - "", - "1) Accomplished", - "- Added `median`, `range`, and `clamp` to the math module with focused coverage.", - "- Did not edit shared files (`src/index.js` / `README.md`).", - "", - "FilesChanged:", - "- Added math utilities in [src/math.js](/tmp/work/src/math.js).", - "- Extended tests in [test/math.test.js](/tmp/work/test/math.test.js).", - "- Wrote [./.ade/checkpoints/worker.md](/tmp/work/.ade/checkpoints/worker.md).", - "", - "TestsRun:", - "- `npm test` (passes)", - "", - "Validation:", - "- Result: PASS (7/7 tests).", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Added `median`, `range`, and `clamp`"); - expect(metadata.lastResultReport?.filesChanged).toEqual(["src/math.js", "test/math.test.js"]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 7, - })); - } finally { - fixture.dispose(); - } - }); - - it("does not treat zero failed tests as a failed Codex final-answer outcome", 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: "testing-worker", - title: "Testing worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation_testing", - phaseName: "Testing", - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "npm test passes.", - evidence: [], - maxRetries: 2, - }, - }, - } - ] - }); - const stepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!stepId) throw new Error("Expected testing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId, - ownerId: "operator" - }); - const sessionId = attempt.executorSessionId ?? preSessionId; - if (!attempt.executorSessionId) { - fixture.db.run( - `update orchestrator_attempts set status = 'running', executor_session_id = ?, completed_at = null where id = ?`, - [sessionId, attempt.id], - ); - fixture.db.run( - `update orchestrator_steps set status = 'running', last_attempt_id = ?, updated_at = ? where id = ?`, - [attempt.id, now, stepId], - ); - } - - fs.writeFileSync( - transcriptPath, - [ - "codex", - "", - "## Summary", - "Completed the testing lane. Executed `npm test` on the merged implementation and confirmed the suite passes fully.", - "", - "## Files Changed", - "- No functional source/test files were modified in this testing step.", - "- Added/updated:", - " - `.ade/checkpoints/testing-worker.md`", - " - `.ade/step-output-testing-worker.md`", - "- Worktree already contains upstream changes in:", - " - `README.md`", - " - `src/index.js`", - "", - "## Tests", - "- `npm test` executed from `/tmp/work`", - " - Result: PASS (13 passed, 0 failed)", - "", - "## Validation", - "- Node test harness executed successfully.", - "", - "## Warnings", - "- No functional test blockers found.", - "- `.ade/` currently appears as untracked in git status and is generated lane metadata.", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.outcome).toBe("succeeded"); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 13, - failed: 0, - })); - expect(metadata.lastResultReport?.filesChanged).not.toEqual(expect.arrayContaining([ - expect.stringContaining(".ade/"), - expect.stringMatching(/^\//), - ])); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex h2 report_result sections from tracked worker transcripts", 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-math-worker", - title: "Implementation math worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation_math", - phaseName: "Implementation math", - }, - } - ] - }); - 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.writeFileSync( - transcriptPath, - [ - "codex", - "`report_result`:", - "", - "## Outcome", - "Completed math worker scope successfully. Added `median`, `range`, and `clamp` to the math module with focused tests, and executed the test suite successfully.", - "", - "## Summary", - "- Implemented `median`, `range`, and `clamp` in `src/math.js`.", - "- Extended `test/math.test.js` with focused coverage for each function.", - "- Did **not** edit `src/index.js` or `README.md` due shared ownership for API/docs consolidation phase.", - "- Recorded checkpoint and step-output per ADE requirements.", - "", - "## Files Changed", - "- `src/math.js`", - "- `test/math.test.js`", - "- `.ade/checkpoints/worker_implementation-math-worker.md`", - "- `.ade/step-output-worker_implementation-math-worker.md`", - "", - "## Tests Run", - "- `npm test`", - "- Result: pass (7/7 tests)", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Implemented `median`, `range`, and `clamp`"); - expect(metadata.lastResultReport?.filesChanged).toEqual(["src/math.js", "test/math.test.js"]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 7, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers natural Codex final-answer sections after prompt templates", 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-math-worker", - title: "Implementation math worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation_math", - phaseName: "Implementation math", - }, - } - ] - }); - 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.writeFileSync( - transcriptPath, - [ - "STEP OUTPUT FILE: When you complete your task, write a structured summary file.", - "Before exiting, call `report_result` with outcome, summary, filesChanged, and testsRun.", - "", - "## Summary", - "1-2 sentence description of what was accomplished.", - "", - "## Files Changed", - "Bulleted list of files created or modified.", - "", - "codex", - "What I accomplished", - "- Completed the math phase: added `median`, `range`, and `clamp` utilities in `src/math.js`.", - "- Added focused `node:test` coverage in `test/math.test.js`.", - "", - "What I changed", - "- [`src/math.js`](file:///tmp/work/src/math.js): added math utilities.", - "- [`test/math.test.js`](file:///tmp/work/test/math.test.js): added tests.", - "- [`.ade/checkpoints/worker.md`](file:///tmp/work/.ade/checkpoints/worker.md): checkpoint.", - "", - "Tests", - "- `npm test`: passed (8 passed, 0 failed).", - "", - "Validation", - "- Confirmed all existing and new tests execute successfully.", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Completed the math phase"); - expect(metadata.lastResultReport?.summary).not.toContain("1-2 sentence"); - expect(metadata.lastResultReport?.filesChanged).toEqual(["src/math.js", "test/math.test.js"]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 8, - failed: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex closeout counts from label-first test summaries", 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: "closeout-worker", - title: "Closeout worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "closeout", - phaseName: "Closeout", - }, - } - ] - }); - const stepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!stepId) throw new Error("Expected closeout 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, - [ - "STEP OUTPUT FILE: When you complete your task, write a structured summary file.", - "Before exiting, call `report_result` with outcome, summary, filesChanged, and testsRun.", - "", - "## Accomplished", - "- This is prompt/template noise and must not become the final summary.", - "", - "TAP version 13", - "# pass 10", - "# fail 0", - "# skipped 0", - "", - "codex", - "Accomplished: closeout verification is complete. The requested math and string utilities are implemented, exported, documented, and covered by tests.", - "", - "What I changed in this phase:", - "- [src/math.js](./src/math.js)", - "- [src/strings.js](./src/strings.js)", - "- [src/index.js](./src/index.js)", - "- [README.md](./README.md)", - "- [test/math.test.js](./test/math.test.js)", - "- [test/strings.test.js](./test/strings.test.js)", - "- [`.ade/step-output-closeout.md`](./.ade/step-output-closeout.md)", - "- [`..checkpoints/closeout.md`](../.ade/checkpoints/closeout.md)", - "", - "Tests:", - "- Ran `npm test` (Node test runner via `node --test`)", - "- Result: `pass 10`, `fail 0`, `skipped 0`", - "", - "Notes / risks:", - "- No known functional risks.", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("closeout verification is complete"); - expect(metadata.lastResultReport?.summary).not.toContain("prompt/template noise"); - expect(metadata.lastResultReport?.filesChanged).toEqual([ - "src/math.js", - "src/strings.js", - "src/index.js", - "README.md", - "test/math.test.js", - "test/strings.test.js", - ]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 10, - failed: 0, - skipped: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("prefers the final Codex report over embedded earlier worker result JSON", 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: "closeout-worker", - title: "Closeout worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "closeout", - phaseName: "Closeout", - }, - } - ] - }); - const stepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!stepId) throw new Error("Expected closeout 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, - [ - "codex", - "Inspecting the run graph before final closeout.", - "exec", - JSON.stringify({ - previousStep: { - lastResultReport: { - outcome: "succeeded", - summary: "Inspected the live run and produced a concrete execution DAG. No files were modified.", - filesChanged: [], - testsRun: null, - plan: { - markdown: [ - "## Plan", - "- Launch contract_author and consumer_impl in parallel.", - "- Integrate, test, and close out.", - ].join("\n"), - }, - }, - }, - }, null, 2), - "", - "codex", - "## report_result", - "- `outcome`: `succeeded`", - "- `summary`: Closeout verification is complete with final file and test evidence.", - "- `filesChanged`:", - " - [`src/statusContract.js`](/tmp/work/src/statusContract.js)", - " - [`src/report.js`](/tmp/work/src/report.js)", - " - [`test/inventory.test.js`](/tmp/work/test/inventory.test.js)", - "- `testsRun`:", - " - `npm test`", - " - pass: 6", - " - fail: 0", - " - skipped: 0", - "", - "## HANDOFF SUMMARY", - "Final closeout should not inherit the earlier planning payload.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Closeout verification is complete"); - expect(metadata.lastResultReport?.summary).not.toContain("execution DAG"); - expect(metadata.lastResultReport?.plan).toBeUndefined(); - expect(metadata.lastResultReport?.filesChanged).toEqual([ - "src/statusContract.js", - "src/report.js", - "test/inventory.test.js", - ]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 6, - failed: 0, - skipped: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex final-answer JSON plans that are missing a trailing top-level brace", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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"); - - const finalPayload = JSON.stringify({ - outcome: "Read-only planning pass complete.", - summary: "Validated custom Codex phase cards and produced the Real60 execution DAG.", - filesChanged: [], - testsRun: [], - plan: { - markdown: [ - "## Mission Plan", - "- Run contract_author and consumer_impl in parallel.", - "- Integrate, test, and close out with ADE evidence.", - ].join("\n"), - metadata: { - phaseModel: "codex/openai/gpt-5.3-codex-spark", - nextCritical: "spawn contract_author + consumer_impl in parallel", - }, - }, - }); - - fs.writeFileSync( - transcriptPath, - [ - "codex", - "Preparing the plan.", - "exec", - JSON.stringify({ - previousStep: { - lastResultReport: { - outcome: "succeeded", - summary: "Earlier embedded planning JSON should not be the chosen payload.", - filesChanged: ["old.js"], - testsRun: null, - }, - }, - }, null, 2), - "", - "codex", - finalPayload.slice(0, -1), - "", - "tokens used", - "44,004", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Real60 execution DAG"); - expect(metadata.lastResultReport?.summary).not.toContain("Earlier embedded"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.testsRun).toBeNull(); - expect(metadata.lastResultReport?.plan?.markdown).toContain("contract_author and consumer_impl in parallel"); - } finally { - fixture.dispose(); - } - }); - - it("prefers final Codex fenced JSON plans over loose prose recovery without a report_result heading", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "`what I accomplished`", - "- Inspected the run graph and phase cards.", - "", - "`what I changed`", - "- No files were modified.", - "- `filesChanged: []`", - "- `testsRun: []`", - "", - "```json", - JSON.stringify({ - outcome: "success", - summary: "Structured planning payload should win over loose prose.", - filesChanged: [], - testsRun: [], - plan: { - markdown: [ - "## Plan", - "- Spawn contract_author and consumer_impl in parallel.", - "- Capture ADE message_worker evidence.", - ].join("\n"), - }, - }, null, 2), - "```", - "", - "`HANDOFF SUMMARY`", - "Structured JSON contains the first-class plan.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Structured planning payload"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("message_worker evidence"); - } finally { - fixture.dispose(); - } - }); - - it("prefers the final Codex bullet report over earlier yamlish planning payloads", 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: "integration-worker", - title: "Integration worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "integration", - phaseName: "Integration", - }, - } - ] - }); - const stepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!stepId) throw new Error("Expected integration 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, - [ - "codex", - "Earlier graph output included an old planning payload.", - "report_result:", - " outcome: complete", - " summary: \"First-class plan is complete and should not be reused.\"", - " filesChanged: []", - " testsRun: []", - " plan:", - " markdown: |", - " # Old planning payload", - " - Do not select this for integration.", - "", - "codex", - "What I accomplished", - "- Completed the integration phase after both parallel peers were finished.", - "- Exported helper APIs and updated docs.", - "", - "What I changed", - "- [src/index.js](/tmp/work/src/index.js)", - "- [README.md](/tmp/work/README.md)", - "- [.ade/checkpoints/worker_integration-worker.md](/tmp/work/.ade/checkpoints/worker_integration-worker.md)", - "", - "Tests", - "- `npm test`: total 6, passed 6, failed 0, skipped 0.", - "", - "`report_result`", - "- outcome: `succeeded`", - "- summary: `Integrated status helper exports + README update after parallel workers.`", - "- filesChanged: `src/index.js`, `README.md`, `.ade/checkpoints/worker_integration-worker.md`", - "- testsRun: `npm test (passed: 6, failed: 0, skipped: 0)`", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Integrated status helper exports"); - expect(metadata.lastResultReport?.summary).not.toContain("First-class plan"); - expect(metadata.lastResultReport?.plan).toBeUndefined(); - expect(metadata.lastResultReport?.filesChanged).toEqual(["src/index.js", "README.md"]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: expect.stringContaining("npm test"), - passed: 6, - failed: 0, - skipped: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex testing summaries without an explicit report_result heading", 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: "testing-worker", - title: "Testing worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "testing", - phaseKey: "testing", - phaseName: "Testing", - }, - } - ] - }); - const stepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!stepId) throw new Error("Expected testing 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, - [ - "exec", - "/bin/zsh -lc \"ade actions run orchestrator_core.addReflection --input-json '{...}'\" in /tmp/work", - "exited 1", - "ade: occurredAt is required and must be a valid ISO-8601 timestamp.", - "", - "codex", - "Testing phase complete.", - "", - "## What I accomplished", - "- Ran required validation and reporting commands for this phase.", - "- Captured exact test counts and exact ADE evidence outputs.", - "", - "## What I changed", - "- [`.ade/checkpoints/worker_testing-worker.md`](.ade/checkpoints/worker_testing-worker.md)", - "- [`.ade/step-output-worker_testing-worker.md`](.ade/step-output-worker_testing-worker.md)", - "", - "## Test results", - "- `npm test`", - "- total: `6`", - "- pass: `6`", - "- fail: `0`", - "- skipped: `0`", - "", - "## Evidence captured", - "- `ade message_worker`: `queued_for_polling` for completed peers.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Ran required validation"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 6, - failed: 0, - skipped: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex closeout summaries that start with implemented", 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: "closeout-worker", - title: "Closeout worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "closeout", - phaseKey: "closeout", - phaseName: "Closeout", - }, - } - ] - }); - const stepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!stepId) throw new Error("Expected closeout 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, - [ - "codex", - "Earlier graph output included a planning payload.", - "report_result:", - " outcome: complete", - " summary: \"First-class plan is complete and should not close out the mission.\"", - " filesChanged: []", - " testsRun: []", - " plan:", - " markdown: |", - " # Old planning payload", - "", - "codex", - "Implemented: Real63 closeout was completed from the active worker context, with upstream phase outputs and ADE evidence consolidated.", - "", - "What I changed:", - "- Added checkpoint: [.ade/checkpoints/worker_closeout-worker.md](/tmp/work/.ade/checkpoints/worker_closeout-worker.md)", - "- Added closeout step output: [.ade/step-output-worker_closeout-worker.md](/tmp/work/.ade/step-output-worker_closeout-worker.md)", - "", - "Mission changes from upstream:", - "- [README.md](/tmp/work/README.md)", - "- [src/index.js](/tmp/work/src/index.js)", - "- [src/report.js](/tmp/work/src/report.js)", - "- [test/inventory.test.js](/tmp/work/test/inventory.test.js)", - "- [src/statusContract.js](/tmp/work/src/statusContract.js)", - "", - "Tests:", - "- contract_author phase: pass 4, fail 0, skip 0", - "- consumer_impl phase: pass 5, fail 0, skip 0", - "- testing phase: pass 5, fail 0, skip 0", - "", - "Risks / notes for downstream:", - "- No product defaults or phase defaults were changed.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Real63 closeout"); - expect(metadata.lastResultReport?.summary).not.toContain("First-class plan"); - expect(metadata.lastResultReport?.plan).toBeUndefined(); - expect(metadata.lastResultReport?.filesChanged).toEqual([ - "README.md", - "src/index.js", - "src/report.js", - "test/inventory.test.js", - "src/statusContract.js", - ]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - passed: 4, - failed: 0, - skipped: 0, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex report_result bullet lists under h3 headings", 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-math-worker", - title: "Implementation math worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - stepType: "implementation", - phaseKey: "implementation_math", - phaseName: "Implementation math", - }, - } - ] - }); - 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.writeFileSync( - transcriptPath, - [ - "Before exiting, call `report_result` with outcome, summary, filesChanged, and testsRun.", - "", - "codex", - "### report_result", - "- outcome: Completed. Implemented math utilities and math tests in this phase only; all tests pass.", - "- summary: Added `median`, `range`, and `clamp` utilities to `src/math.js`, and added focused `node:test` coverage in `test/math.test.js`.", - "- filesChanged: 2", - " - [`src/math.js`](/tmp/work/src/math.js)", - " - [`test/math.test.js`](/tmp/work/test/math.test.js)", - "- testsRun: `npm test` (pass; 14 total tests, 14 passed)", - "", - "### HANDOFF SUMMARY", - "Downstream should wire exports and docs later.", - "diff --git a/src/math.js b/src/math.js", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("Added `median`, `range`, and `clamp`"); - expect(metadata.lastResultReport?.filesChanged).toEqual(["src/math.js", "test/math.test.js"]); - expect(metadata.lastResultReport?.testsRun).toEqual(expect.objectContaining({ - command: "npm test", - passed: 14, - })); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex yamlish report_result plans without treating target files as changes", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "Return the plan through report_result with a first-class plan payload.", - "", - "codex", - "report_result:", - " outcome: \"Completed planning-only inspection and produced a concrete execution plan.\"", - " summary: \"I completed a read-only sweep and split math/string work into parallel workers.\"", - " filesChanged: []", - " testsRun: []", - " plan:", - " markdown: |", - " ## Context summary", - " - Current API is split into `src/math.js` and `src/strings.js`.", - "", - " ## Target files", - " - `[src/math.js](/tmp/work/src/math.js)`", - " - `[src/strings.js](/tmp/work/src/strings.js)`", - " - `[src/index.js](/tmp/work/src/index.js)`", - " - `[README.md](/tmp/work/README.md)`", - "", - " handoffSummary:", - " - \"No code changes were made in planning.\"", - "", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("read-only sweep"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.testsRun).toBeNull(); - expect(metadata.lastResultReport?.plan?.markdown).toContain("## Context summary"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("src/index.js"); - } finally { - fixture.dispose(); - } - }); - - it("prefers Codex fenced JSON report_result plans over lossy final-answer parsing", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "### What I accomplished", - "- Completed a read-only planning scan.", - "", - "### report_result", - "```json", - JSON.stringify({ - outcome: "Read-only planning complete; implementation plan is ready.", - summary: "Split math and string work into parallel streams, then consolidate docs and exports.", - filesChanged: [], - testsRun: [], - plan: { - metadata: { - owner: "planning-worker", - phase: "read-only planning", - }, - markdown: [ - "- Math worker owns `src/math.js` and `test/math.test.js`.", - "- String worker owns `src/strings.js` and `test/strings.test.js`.", - "- API/docs worker owns `src/index.js` and `README.md` after both streams finish.", - ].join("\n"), - }, - }, null, 2), - "```", - "", - "### HANDOFF SUMMARY", - "- No files were edited.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("parallel streams"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.testsRun).toBeNull(); - expect(metadata.lastResultReport?.plan?.markdown).toContain("API/docs worker"); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex final-answer fenced planning markdown as report_result.plan.markdown", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "Accomplished:", - "- Completed read-only planning and confirmed the custom Codex phases.", - "", - "What changed:", - "- None; planning is read-only.", - "", - "Plan (for orchestration handoff):", - "```markdown", - "## Plan: Real49 parallel worker lane proof", - "", - "### 1) Parallel execution", - "- `contract_author` owns `src/statusContract.js` and focused tests.", - "- `consumer_impl` owns status-aware report output.", - "", - "### 2) Closeout", - "- Report worker paths, messaging outcome, and exact test counts.", - "```", - "", - "`report_result` payload fields:", - "- `outcome`: planning complete", - "- `summary`: validated fixture structure and phase ordering", - "", - "Handoff summary:", - "- No files were edited.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("read-only planning"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.plan?.markdown).toContain("Real49 parallel worker lane proof"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("consumer_impl"); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex list-style report_result planning markdown", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "report_result", - "- outcome: `planning-complete`", - "- summary: `Prepared a concrete DAG for two custom Codex workers.`", - "- filesChanged: `[]`", - "- testsRun: `[]`", - "- plan:", - " - markdown: |", - " ## Plan for Real50 mission", - "", - " ### Parallel custom phases", - " - `contract_author` owns the shared status contract.", - " - `consumer_impl` owns the report consumer.", - "", - " ### Integration", - " - Verify worker messaging and exact test counts.", - " - metadata:", - " parallelism: required", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("concrete DAG"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("Plan for Real50 mission"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("consumer_impl"); - expect(metadata.lastResultReport?.plan?.markdown).not.toContain("metadata:"); - } finally { - fixture.dispose(); - } - }); - - it("recovers glued Codex report_result bullets with direct plan.markdown", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "I’ll keep this strictly read-only and just ground the smoke-plan result in the mission context already provided.### report_result", - "- outcome: succeeded", - "- summary: Read the current mission context and produced the required minimal read-only planning payload; no repository inspection or edits were needed.", - "- filesChanged: []", - "- testsRun: read-only mission-context review only", - "- plan.markdown:", - " ## Plan", - " - What was learned: this is a throwaway ADE Missions smoke test for the normal chat runtime.", - " - Recommended next steps: the coordinator should accept this result and stop the mission cleanly.", - " - Risks or stop conditions: stop if any worker attempts mutation, PR, merge, push, or main activity.", - "", - "### Accomplished", - "- Produced the required report_result-shaped planning response.", - "", - "### Changed Files", - "- None.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("minimal read-only planning payload"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.plan?.markdown).toContain("throwaway ADE Missions smoke test"); - expect(metadata.lastResultReport?.plan?.markdown).not.toContain("Accomplished"); - } finally { - fixture.dispose(); - } - }); - - it("recovers chunked agent chat report_result bullets with direct plan.markdown", 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}.chat.jsonl`); - 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-chat', 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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" - }); - fixture.db.run( - ` - update orchestrator_attempts - set status = 'running', - executor_session_id = ?, - executor_kind = 'codex', - error_class = 'none', - error_message = null, - completed_at = null - where id = ? - `, - [preSessionId, attempt.id] - ); - - const chunks = [ - "I’ll do the minimal read-only check for mission context, then return the required planning result with a first-class `plan.markdown`.### report_result\n- outcome: succeeded\n- summary", - ": Produced the required read-only planning payload for the mission without source edits.\n", - "- filesChanged: []\n", - "- testsRun: Read-only mission context inspection only.\n- plan", - ".markdown:\n ```markdown\n ## Plan\n - What was learned: This is a throwaway ADE Missions smoke test for chunked chat transcript recovery.\n - Recommended next steps: The coordinator should accept this result and continue or close cleanly.\n - Risks or stop conditions: Stop if any worker attempts mutation, PR, merge, push, or main activity.\n ```\n\n### HANDOFF SUMMARY\nThe plan was returned in the required field.", - ]; - fs.writeFileSync( - transcriptPath, - chunks - .map((text, index) => JSON.stringify({ - sessionId: preSessionId, - timestamp: now, - sequence: index + 1, - event: { - type: "text", - text, - messageId: "assistant-message-1", - }, - })) - .join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: preSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("read-only planning payload"); - expect(metadata.lastResultReport?.filesChanged).toEqual([]); - expect(metadata.lastResultReport?.plan?.markdown).toContain("chunked chat transcript recovery"); - expect(metadata.lastResultReport?.plan?.markdown).not.toContain("HANDOFF SUMMARY"); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex REPORT_RESULT plan.markdown artifacts", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "[REPORT_RESULT]", - "", - "- What I accomplished:", - " - Completed a read-only planning pass.", - " - Ran ADE CLI aliases and captured failures.", - "", - "- Plan artifact (first-class):", - " - `plan.markdown`", - " ```md", - " # Real51 Mission Plan", - " ## Parallel track A", - " - contract_author owns statusContract.js.", - " ## Parallel track B", - " - consumer_impl owns status-aware report output.", - " ```", - "", - "- testsRun: `[]`", - "", - "HANDOFF SUMMARY:", - "1. Planning completed.", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.summary).toContain("read-only planning"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("Real51 Mission Plan"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("consumer_impl"); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex fenced JSON plan payload markdown", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "### What I accomplished", - "- Completed planning inspection.", - "", - "### plan (first-class payload)", - "```json", - JSON.stringify({ - markdown: [ - "## Plan: Real52 inventory status feature", - "", - "### contract_author", - "- Add statusContract.js.", - "", - "### consumer_impl", - "- Add status-aware report output.", - ].join("\n"), - metadata: { requiredParallelPhases: ["contract_author", "consumer_impl"] }, - }, null, 2), - "```", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.plan?.markdown).toContain("Real52 inventory status feature"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("consumer_impl"); - } finally { - fixture.dispose(); - } - }); - - it("recovers Codex plan.markdown heading artifacts", 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: { - stepType: "planning", - phaseKey: "planning", - phaseName: "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, - [ - "codex", - "### report_result", - "- **outcome:** Read-only planning complete.", - "- **summary:** Prepared the phase plan.", - "- **filesChanged:** `[]`", - "- **testsRun:** `[]`", - "", - "### plan.markdown", - "```markdown", - "## Objective", - "Run contract_author and consumer_impl in parallel.", - "```", - ].join("\n"), - "utf8" - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: attempt.executorSessionId, - laneId: fixture.laneId, - exitCode: 0 - }); - expect(reconciled).toBe(1); - - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.id === stepId); - const metadata = (step?.metadata ?? {}) as Record<string, any>; - expect(step?.status).toBe("succeeded"); - expect(metadata.recoveredResultReportFromTranscript).toBe(true); - expect(metadata.lastResultReport?.plan?.markdown).toContain("contract_author"); - expect(metadata.lastResultReport?.plan?.markdown).toContain("consumer_impl"); - } finally { - fixture.dispose(); - } - }); - - it("fails a planning worker that exits cleanly with only shell bootstrap noise", 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, 'claude-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: { - stepType: "planning", - readOnlyExecution: true, - }, - } - ] - }); - 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, - [ - "ADE_MISSION_ID='mission-1' ADE_RUN_ID='run-1' exec claude --model 'sonnet' --permission-mode 'default'", - "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.legacy-cli/get-codex-token.sh", - "/Users/admin/.legacy-cli/completions/legacy.zsh:3803: command not found: compdef", - "admin@Mac test-10-f4bb12de %", - "-p \"$(cat '/Users/admin/Projects/ADE/.ade/orchestrator/worker-prompts/worker-123.txt')\"", - ].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?.errorMessage).toBe("Planning worker exited before producing any assistant or tool activity."); - expect(after?.errorClass).toBe("startup_failure"); - } finally { - fixture.dispose(); - } - }); - - it("classifies planning workers with lifecycle-only chat transcripts 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}.chat.jsonl`); - 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, 'claude-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: { - stepType: "planning", - readOnlyExecution: true, - }, - } - ] - }); - 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, - [ - JSON.stringify({ - sessionId: attempt.executorSessionId, - timestamp: now, - event: { - type: "status", - turnStatus: "started", - }, - }), - ].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 started but was interrupted before producing any assistant or tool activity."); - } finally { - fixture.dispose(); - } - }); - - it("classifies tracked workers interrupted during an open shell command as failed even with exit code zero", 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.writeFileSync( - transcriptPath, - [ - "codex", - "I’m opening the required coordination window before making changes.", - "exec", - "/bin/zsh -lc 'node scripts/coordination-wait.js' 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("Worker session was interrupted while a shell command was still running before report_result."); - } finally { - fixture.dispose(); - } - }); - - 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-attempt-${attempt.id}.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<string, unknown>; - const report = metadata.lastResultReport as Record<string, unknown>; - 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("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<string, unknown>; - const report = metadata.lastResultReport as Record<string, unknown>; - 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 { - 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: <one sentence>", - "- filesChanged: []", - "- testsRun: <commands run, or read-only checks only>", - "- 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<string, unknown>; - 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 { - const now = "2026-02-19T00:00:00.000Z"; - const transcriptDir = path.join(fixture.projectRoot, ".ade", "transcripts"); - fs.mkdirSync(transcriptDir, { recursive: true }); - // Pre-insert terminal_sessions row so startAttempt finds it when the default adapter returns. - const preSessionId = "session-1"; - 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, path.join(transcriptDir, `${preSessionId}.log`), now] - ); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "first", - title: "First", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode" - } - ] - }); - const firstStepId = fixture.service.listSteps(started.run.id)[0]?.id; - if (!firstStepId) throw new Error("Expected first step"); - - const firstAttempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: firstStepId, - ownerId: "operator" - }); - if (!firstAttempt.executorSessionId) throw new Error("Expected running session-backed attempt"); - fs.writeFileSync( - path.join(transcriptDir, `${firstAttempt.executorSessionId}.log`), - "Completed the worker step and reported the outcome.\n", - "utf8" - ); - - // Update the pre-inserted row to simulate a completed session (for deriving status). - fixture.db.run( - `update terminal_sessions set status = 'completed', ended_at = ? where id = ?`, - ["2026-02-20T00:05:00.000Z", firstAttempt.executorSessionId] - ); - - const reconciled = await fixture.service.onTrackedSessionEnded({ - sessionId: firstAttempt.executorSessionId, - laneId: fixture.laneId, - exitCode: null - }); - expect(reconciled).toBe(1); - - const after = fixture.service.listAttempts({ runId: started.run.id }); - const refreshed = after.find((attempt) => attempt.id === firstAttempt.id); - expect(refreshed?.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("records docs truncation and context provenance metadata in snapshots", async () => { - const fixture = await createFixture(); - try { - const docsRoot = path.join(fixture.projectRoot, "docs", "architecture"); - fs.mkdirSync(docsRoot, { recursive: true }); - fs.writeFileSync(path.join(docsRoot, "HUGE.md"), "x".repeat(20_000), "utf8"); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "docs", - title: "Docs", - stepIndex: 0, - policy: { - docsMaxBytes: 64 - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner-docs" - }); - expect(attempt.contextSnapshotId).toBeTruthy(); - const snapshot = fixture.service - .listContextSnapshots({ runId: started.run.id }) - .find((entry) => entry.id === attempt.contextSnapshotId); - expect(snapshot?.cursor.docsMode).toBe("digest_ref"); - expect(snapshot?.cursor.docsBudgetBytes).toBe(64); - } finally { - fixture.dispose(); - } - }); - - it("summarizes older mission handoffs in context snapshots to limit context bloat", async () => { - const fixture = await createFixture(); - try { - const now = "2026-02-20T00:00:00.000Z"; - fixture.db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values ('mstep-handoff', ?, ?, 0, 'Implement', null, 'implementation', ?, 'pending', '{"stepType":"implementation"}', ?, ?, null, null) - `, - [fixture.missionId, fixture.projectId, fixture.laneId, now, now] - ); - - for (let index = 0; index < 20; index += 1) { - fixture.db.run( - ` - insert into mission_step_handoffs( - id, - project_id, - mission_id, - mission_step_id, - run_id, - step_id, - attempt_id, - handoff_type, - producer, - payload_json, - created_at - ) values (?, ?, ?, ?, null, null, null, ?, 'orchestrator', ?, ?) - `, - [ - `handoff-${index}`, - fixture.projectId, - fixture.missionId, - "mstep-handoff", - index % 2 === 0 ? "attempt_succeeded" : "attempt_failed", - JSON.stringify({ index }), - new Date(Date.parse(now) + index * 1_000).toISOString() - ] - ); - } - - const started = fixture.service.startRunFromMission({ - missionId: fixture.missionId, - runMode: "manual", - defaultExecutorKind: "manual" - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner-handoff", - executorKind: "manual" - }); - const snapshot = fixture.service - .listContextSnapshots({ runId: started.run.id }) - .find((entry) => entry.id === attempt.contextSnapshotId); - expect(snapshot?.cursor.missionHandoffIds?.length).toBe(12); - expect(snapshot?.cursor.missionHandoffDigest?.summarizedCount).toBe(8); - expect(snapshot?.cursor.missionHandoffDigest?.byType?.attempt_failed).toBeGreaterThan(0); - expect(snapshot?.cursor.missionHandoffDigest?.byType?.attempt_succeeded).toBeGreaterThan(0); - } finally { - fixture.dispose(); - } - }); - - it("normalizes adapter envelopes and supports deterministic integration chain blocking", async () => { - const conflictService = { - prepareResolverSession: async () => ({ - runId: "resolver-1", - promptFilePath: "/tmp/prompt.md", - cwdWorktreePath: "/tmp/worktree", - cwdLaneId: "lane-1", - integrationLaneId: "lane-integration", - warnings: [], - contextGaps: [], - status: "ready" as const - }) - }; - const fixture = await createFixture({ - conflictService - }); - try { - fixture.service.registerExecutorAdapter({ - kind: "opencode", - start: async () => ({ - status: "completed", - result: { - success: true, - summary: "adapter completed", - warnings: "not-array" - } as any - }) - }); - - const adapterRun = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "adapter", - title: "Adapter Step", - stepIndex: 0, - executorKind: "opencode" - } - ] - }); - const adapterStep = fixture.service.listSteps(adapterRun.run.id)[0]; - if (!adapterStep) throw new Error("Missing adapter step"); - const adapterAttempt = await fixture.service.startAttempt({ - runId: adapterRun.run.id, - stepId: adapterStep.id, - ownerId: "owner" - }); - expect(adapterAttempt.status).toBe("succeeded"); - expect(adapterAttempt.resultEnvelope?.schema).toBe("ade.orchestratorAttempt.v1"); - expect(Array.isArray(adapterAttempt.resultEnvelope?.warnings)).toBe(true); - - const integrationRun = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "integration", - title: "Integration", - stepIndex: 0, - metadata: { - integrationFlow: true, - targetLaneId: "lane-target", - sourceLaneIds: ["lane-source"] - } - } - ] - }); - const integrationStep = fixture.service.listSteps(integrationRun.run.id)[0]; - if (!integrationStep) throw new Error("Missing integration step"); - const integrationAttempt = await fixture.service.startAttempt({ - runId: integrationRun.run.id, - stepId: integrationStep.id, - ownerId: "owner" - }); - expect(integrationAttempt.status).toBe("blocked"); - expect(integrationAttempt.errorClass).toBe("policy"); - const timeline = fixture.service.listTimeline({ runId: integrationRun.run.id, limit: 50 }); - expect(timeline.some((entry) => entry.eventType === "integration_chain_stage")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("applies mission-level permission overrides even when project ai.permissions are absent", async () => { - const fixture = await createFixture(); - try { - fixture.db.run( - `update missions set metadata_json = ? where id = ? and project_id = ?`, - [ - JSON.stringify({ - launch: { - permissionConfig: { - cli: { mode: "edit", sandboxPermissions: "danger-full-access" }, - inProcess: { mode: "plan" } - } - } - }), - fixture.missionId, - fixture.projectId - ] - ); - - let capturedPermissionConfig: Record<string, unknown> | undefined; - fixture.service.registerExecutorAdapter({ - kind: "opencode", - start: async (args) => { - capturedPermissionConfig = args.permissionConfig as Record<string, unknown> | undefined; - return { - status: "completed", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "ok", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false - } - }; - } - }); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "permissions-override", - title: "Permissions override", - stepIndex: 0, - executorKind: "opencode", - metadata: { - modelId: "anthropic/claude-sonnet-4-6" - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - - expect(attempt.status).toBe("succeeded"); - const cli = capturedPermissionConfig?.cli as Record<string, unknown> | undefined; - const inProcess = capturedPermissionConfig?.inProcess as Record<string, unknown> | undefined; - expect(cli?.mode).toBe("edit"); - expect(cli?.sandboxPermissions).toBe("danger-full-access"); - expect(inProcess?.mode).toBe("plan"); - } finally { - fixture.dispose(); - } - }); - - it("threads project provider permissions into executor launch config", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "danger-full-access" }, - inProcess: { mode: "full-auto" }, - providers: { - claude: "edit", - codex: "config-toml", - opencode: "full-auto", - codexSandbox: "read-only", - allowedTools: ["Bash"], - }, - }, - }, - }, - }), - }, - }); - try { - fixture.db.run( - `update missions set metadata_json = ? where id = ? and project_id = ?`, - [ - JSON.stringify({ - launch: { - permissionConfig: { - inProcess: { mode: "plan" }, - }, - }, - }), - fixture.missionId, - fixture.projectId, - ] - ); - - let capturedPermissionConfig: Record<string, unknown> | undefined; - fixture.service.registerExecutorAdapter({ - kind: "opencode", - start: async (args) => { - capturedPermissionConfig = args.permissionConfig as Record<string, unknown> | undefined; - return { - status: "completed", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "ok", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false - } - }; - } - }); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "project-provider-permissions", - title: "Project provider permissions", - stepIndex: 0, - executorKind: "opencode", - metadata: { - modelId: "anthropic/claude-sonnet-4-6" - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - - expect(attempt.status).toBe("succeeded"); - const cli = capturedPermissionConfig?.cli as Record<string, unknown> | undefined; - const inProcess = capturedPermissionConfig?.inProcess as Record<string, unknown> | undefined; - const providers = capturedPermissionConfig?._providers as Record<string, unknown> | undefined; - expect(cli?.mode).toBe("edit"); - expect(cli?.sandboxPermissions).toBe("read-only"); - expect(inProcess?.mode).toBe("plan"); - expect(providers?.codex).toBe("config-toml"); - expect(providers?.opencode).toBe("plan"); - expect(providers?.allowedTools).toEqual(["Bash"]); - } finally { - fixture.dispose(); - } - }); - - it("does not let Claude full-auto defaults bypass Codex sandbox flags", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - providers: { - claude: "full-auto", - codex: "default", - codexSandbox: "workspace-write", - }, - }, - }, - }, - }), - }, - }); - 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"; - 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, path.join(transcriptDir, `${preSessionId}.log`), now] - ); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "codex-permissions", - title: "Codex permissions", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - modelId: "openai/gpt-5.3-codex", - }, - }, - ], - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner", - }); - - expect(attempt.status).toBe("running"); - const startupCommand = String(fixture.ptyCreateCalls[0]?.startupCommand ?? ""); - expect(startupCommand).toContain("codex"); - expect(startupCommand).toMatch(/(?:--sandbox|-s)\s+'?workspace-write'?/); - expect(startupCommand).toMatch(/(?:--ask-for-approval|-a)\s+'?on-request'?/); - expect(startupCommand).not.toContain("--dangerously-bypass-approvals-and-sandbox"); - } finally { - fixture.dispose(); - } - }); - - it("uses safe permission defaults when project and mission permission settings are missing", async () => { - const fixture = await createFixture(); - try { - let capturedPermissionConfig: Record<string, unknown> | undefined; - fixture.service.registerExecutorAdapter({ - kind: "opencode", - start: async (args) => { - capturedPermissionConfig = args.permissionConfig as Record<string, unknown> | undefined; - return { - status: "completed", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "ok", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false - } - }; - } - }); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "permissions-defaults", - title: "Permissions defaults", - stepIndex: 0, - executorKind: "opencode", - metadata: { - modelId: "openai/gpt-5.3-codex" - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - - expect(attempt.status).toBe("succeeded"); - const cli = capturedPermissionConfig?.cli as Record<string, unknown> | undefined; - const inProcess = capturedPermissionConfig?.inProcess as Record<string, unknown> | undefined; - expect(cli?.mode).toBe("full-auto"); - expect(cli?.sandboxPermissions).toBe("workspace-write"); - expect(inProcess?.mode).toBe("full-auto"); - } finally { - fixture.dispose(); - } - }); - - it("runs non-CLI opencode attempts in-process without spawning terminal sessions", async () => { - const executeTask = vi.fn(async () => ({ - text: "api/local execution completed", - structuredOutput: { ok: true }, - sessionId: null - })); - const fixture = await createFixture({ - aiIntegrationService: { - executeTask - } - }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "api-worker", - title: "API worker", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - modelId: "opencode/openai/gpt-5.4" - } - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - - expect(attempt.status).toBe("succeeded"); - expect(attempt.resultEnvelope?.trackedSession).toBe(false); - expect(fixture.ptyCreateCalls).toHaveLength(0); - expect(executeTask).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: fixture.projectId, - runId: started.run.id, - stepId: step.id, - attemptId: attempt.id, - }) - ); - } finally { - fixture.dispose(); - } - }); - - it("enforces total budget limit in startAttempt", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - maxTotalTokenBudget: 50_000 - } - } - } - }) - } - }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { tokensConsumed: 60_000 }, - steps: [{ stepKey: "expensive", title: "Expensive", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - await expect( - fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }) - ).rejects.toThrow(/budget exceeded/i); - - const runs = fixture.service.listRuns({ missionId: fixture.missionId }); - const run = runs.find((r) => r.id === started.run.id); - expect(run?.status).toBe("paused"); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline.some((e) => e.eventType === "budget_exceeded")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("accumulates budget from attempt metadata in completeAttempt and pauses when exceeded", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - maxTotalTokenBudget: 100_000 - } - } - } - }) - } - }); - try { - // 3 steps: a → b → c. After a and b complete, budget exceeds. c remains pending so run doesn't finalize. - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "step-a", title: "Step A", stepIndex: 0 }, - { stepKey: "step-b", title: "Step B", stepIndex: 1, dependencyStepKeys: ["step-a"] }, - { stepKey: "step-c", title: "Step C", stepIndex: 2, dependencyStepKeys: ["step-b"] } - ] - }); - const stepA = fixture.service.listSteps(started.run.id).find((s) => s.stepKey === "step-a"); - if (!stepA) throw new Error("Missing step-a"); - - const attemptA = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: stepA.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attemptA.id, - status: "succeeded", - metadata: { tokensConsumed: 70_000 } - }); - - // Budget should be accumulated - const timeline1 = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline1.some((e) => e.eventType === "budget_updated")).toBe(true); - - const stepB = fixture.service.listSteps(started.run.id).find((s) => s.stepKey === "step-b"); - if (!stepB) throw new Error("Missing step-b"); - const attemptB = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: stepB.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attemptB.id, - status: "succeeded", - metadata: { tokensConsumed: 50_000 } - }); - - // Total is now 120,000, exceeding 100,000 limit. Step C is still pending so run pauses. - const runs = fixture.service.listRuns({ missionId: fixture.missionId }); - const run = runs.find((r) => r.id === started.run.id); - expect(run?.status).toBe("paused"); - - const timeline2 = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline2.some((e) => e.eventType === "budget_exceeded")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("keeps tick as a no-op for paused runs", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "paused-guard", title: "Paused guard", stepIndex: 0 }] - }); - - const paused = fixture.service.pauseRun({ - runId: started.run.id, - reason: "manual_pause_for_review" - }); - expect(paused.status).toBe("paused"); - - const before = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - const timelineBefore = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }).length; - - const ticked = fixture.service.tick({ runId: started.run.id }); - const after = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - const timelineAfter = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }).length; - - expect(ticked.status).toBe("paused"); - expect(after?.status).toBe("paused"); - expect(after?.updatedAt).toBe(before?.updatedAt); - expect(timelineAfter).toBe(timelineBefore); - } finally { - fixture.dispose(); - } - }); - - it("keeps tick as a no-op for completing runs", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "completion-guard", title: "Completion guard", stepIndex: 0 }] - }); - - const completing = fixture.service.requestCompletion(started.run.id); - expect(completing.status).toBe("completing"); - - const before = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - const timelineBefore = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }).length; - - const ticked = fixture.service.tick({ runId: started.run.id }); - const after = fixture.service.listRuns({ missionId: fixture.missionId }).find((run) => run.id === started.run.id); - const timelineAfter = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }).length; - - expect(ticked.status).toBe("completing"); - expect(after?.status).toBe("completing"); - expect(after?.updatedAt).toBe(before?.updatedAt); - expect(timelineAfter).toBe(timelineBefore); - } finally { - fixture.dispose(); - } - }); - - it("adds steps to a running run with dependency resolution", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "existing-a", title: "Existing A", stepIndex: 0 }, - { stepKey: "existing-b", title: "Existing B", stepIndex: 1, dependencyStepKeys: ["existing-a"] } - ] - }); - - const newSteps = fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { stepKey: "new-c", title: "New C", stepIndex: 2, dependencyStepKeys: ["existing-b"] }, - { stepKey: "new-d", title: "New D", stepIndex: 3, dependencyStepKeys: ["new-c"] } - ] - }); - - expect(newSteps).toHaveLength(2); - expect(newSteps[0]?.stepKey).toBe("new-c"); - expect(newSteps[1]?.stepKey).toBe("new-d"); - - // new-c should depend on existing-b - const existingB = fixture.service.listSteps(started.run.id).find((s) => s.stepKey === "existing-b"); - expect(newSteps[0]?.dependencyStepIds).toContain(existingB?.id); - - // new-d should depend on new-c - expect(newSteps[1]?.dependencyStepIds).toContain(newSteps[0]?.id); - - // Timeline should show step_registered events - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - const registeredEvents = timeline.filter((e) => e.eventType === "step_registered" && e.reason === "add_steps"); - expect(registeredEvents.length).toBe(2); - } finally { - fixture.dispose(); - } - }); - - it("rejects invalid dependency edges when adding steps", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "seed", title: "Seed", stepIndex: 0 }] - }); - - expect(() => - fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "bad-edge", - title: "Bad Edge", - stepIndex: 1, - dependencyStepKeys: ["does_not_exist"] - } - ] - }) - ).toThrow(/unknown dependency/i); - expect(fixture.service.listSteps(started.run.id)).toHaveLength(1); - - expect(() => - fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "cycle-a", - title: "Cycle A", - stepIndex: 1, - dependencyStepKeys: ["cycle-b"] - }, - { - stepKey: "cycle-b", - title: "Cycle B", - stepIndex: 2, - dependencyStepKeys: ["cycle-a"] - } - ] - }) - ).toThrow(/dependency cycle/i); - expect(fixture.service.listSteps(started.run.id)).toHaveLength(1); - } finally { - fixture.dispose(); - } - }); - - it("rejects duplicate step keys in addSteps", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "original", title: "Original", stepIndex: 0 }] - }); - - expect(() => - fixture.service.addSteps({ - runId: started.run.id, - steps: [{ stepKey: "original", title: "Duplicate", stepIndex: 1 }] - }) - ).toThrow(/duplicate/i); - } finally { - fixture.dispose(); - } - }); - - it("skips a step and unblocks downstream dependencies", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "skip-me", title: "Skip Me", stepIndex: 0 }, - { stepKey: "downstream", title: "Downstream", stepIndex: 1, dependencyStepKeys: ["skip-me"] } - ] - }); - const [skipStep, downstream] = fixture.service.listSteps(started.run.id); - if (!skipStep || !downstream) throw new Error("Missing steps"); - - // downstream should not be ready yet - expect(downstream.status).toBe("pending"); - - const skipped = fixture.service.skipStep({ - runId: started.run.id, - stepId: skipStep.id, - reason: "Not needed" - }); - expect(skipped.status).toBe("skipped"); - - // downstream should now be ready since its dependency was skipped - const updatedDownstream = fixture.service.listSteps(started.run.id).find((s) => s.id === downstream.id); - expect(updatedDownstream?.status).toBe("ready"); - - // Timeline should show skip event - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline.some((e) => e.eventType === "step_skipped")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("rejects skip on terminal step", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "done", title: "Done", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ attemptId: attempt.id, status: "succeeded" }); - - expect(() => - fixture.service.skipStep({ - runId: started.run.id, - stepId: step.id - }) - ).toThrow(/terminal/i); - } finally { - fixture.dispose(); - } - }); - - it("allows an explicitly superseded failed step to be skipped with rationale", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "failed-but-superseded", title: "Failed But Superseded", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorMessage: "Validation reported contradicting evidence." - }); - - expect(() => - fixture.service.skipStep({ - runId: started.run.id, - stepId: step.id, - reason: "Superseded by replacement validation." - }) - ).toThrow(/terminal/i); - - const skipped = fixture.service.skipStep({ - runId: started.run.id, - stepId: step.id, - reason: "Superseded by replacement validation.", - allowTerminal: true - }); - expect(skipped.status).toBe("skipped"); - expect(skipped.metadata).toMatchObject({ - skippedFromStatus: "failed", - skippedReason: "Superseded by replacement validation." - }); - } finally { - fixture.dispose(); - } - }); - - it("evaluates and persists gate reports with deterministic thresholds", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "gate-step", title: "Gate Step", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "blocked", - errorClass: "policy", - errorMessage: "insufficient_context:missing_pack" - }); - - const report = fixture.service.getLatestGateReport({ refresh: true }); - expect(report.generatedBy).toBe("deterministic_kernel"); - expect(report.gates.length).toBe(4); - const blockedGate = report.gates.find((gate) => gate.key === "blocked_run_rate_insufficient_context"); - expect(blockedGate?.status).toBe("fail"); - expect((blockedGate?.metadata?.reasonCodes as string[] | undefined)?.some((reason) => reason.includes("insufficient_context"))).toBe( - true - ); - - const persisted = fixture.service.getLatestGateReport(); - expect(persisted.id).toBe(report.id); - } finally { - fixture.dispose(); - } - }); - - it("accepted attempt triggers autopilot advancement for sibling ready steps", async () => { - const fixture = await createFixture(); - try { - // Register an adapter that returns "accepted" with a sessionId. - // We need a terminal_sessions row so the session lookup succeeds. - const now = "2026-02-19T00:00:00.000Z"; - const transcriptDir = path.join(fixture.projectRoot, ".ade", "transcripts"); - fs.mkdirSync(transcriptDir, { recursive: true }); - - let adapterCallCount = 0; - fixture.service.registerExecutorAdapter({ - kind: "opencode", - start: async () => { - adapterCallCount += 1; - const sessionId = `adapter-session-${adapterCallCount}`; - // Insert a terminal_sessions row for each accepted session - 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, ?) - `, - [sessionId, fixture.laneId, now, path.join(transcriptDir, `${sessionId}.log`), now] - ); - return { - status: "accepted" as const, - sessionId, - metadata: { adapterKind: "opencode" } - }; - } - }); - - // Create a run with 2 independent steps (no dependencies) in autopilot mode - const now2 = "2026-02-19T00:00:00.000Z"; - fixture.db.run( - ` - insert into mission_steps( - id, mission_id, project_id, step_index, title, detail, kind, - lane_id, status, metadata_json, created_at, updated_at, started_at, completed_at - ) values - ('mstep-a', ?, ?, 0, 'Step A', null, 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null), - ('mstep-b', ?, ?, 1, 'Step B', null, 'implementation', ?, 'pending', '{"stepType":"implementation","dependencyStepKeys":[]}', ?, ?, null, null) - `, - [ - fixture.missionId, fixture.projectId, fixture.laneId, now2, now2, - fixture.missionId, fixture.projectId, fixture.laneId, now2, now2 - ] - ); - - const started = fixture.service.startRunFromMission({ - missionId: fixture.missionId, - runMode: "autopilot", - defaultExecutorKind: "opencode", - metadata: { plannerParallelismCap: 4 } - }); - - // Wait for the async autopilot advancement triggered by startRun (initial pass) - // plus the deferred accepted_step_advance pass (50ms) for the second step - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Both steps should now be running because: - // 1. The first step's adapter returns "accepted" - // 2. The accepted-step-advance logic fires startReadyAutopilotAttempts - // 3. The second step (also ready) gets started - const attempts = fixture.service.listAttempts({ runId: started.run.id }); - const runningAttempts = attempts.filter((a) => a.status === "running"); - expect(runningAttempts.length).toBeGreaterThanOrEqual(2); - expect(adapterCallCount).toBeGreaterThanOrEqual(2); - - // Verify the attempts have sessionIds attached - for (const attempt of runningAttempts) { - expect(attempt.executorSessionId).toBeTruthy(); - } - - // Verify timeline has autopilot_advance event - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }); - expect(timeline.some((e) => e.eventType === "autopilot_advance")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("accepted attempt with missing terminal session still marks attempt running", async () => { - const fixture = await createFixture(); - try { - // Register adapter that returns accepted with a sessionId, but do NOT insert - // a terminal_sessions row — the session doesn't exist in the database. - fixture.service.registerExecutorAdapter({ - kind: "opencode", - start: async () => ({ - status: "accepted" as const, - sessionId: "ghost-session-999", - metadata: { adapterKind: "opencode" } - }) - }); - - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "orphan-session-step", - title: "Step with missing session", - stepIndex: 0, - executorKind: "opencode" - } - ] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - - // The attempt should be marked as failed because the session row doesn't - // exist in terminal_sessions — the P0 fix catches this and fails fast. - expect(attempt.status).toBe("failed"); - expect(attempt.errorClass).toBe("executor_failure"); - expect(attempt.errorMessage).toContain("Session row not found"); - - // Timeline should have executor_session_missing event - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 50 }); - expect(timeline.some((e) => e.eventType === "executor_session_missing")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("auto-spawns one dedicated validator and emits validation_contract_unfulfilled on required step success", async () => { - const fixture = await createFixture(); - try { - const phaseCard = { - id: "phase-implementation", - phaseKey: "implementation", - name: "Implementation", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true, criteria: "Validator must pass before moving on" }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }; - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [phaseCard] }, - phaseRuntime: { - currentPhaseKey: "implementation", - currentPhaseName: "Implementation", - currentPhaseModel: { - provider: "openai", - modelId: "openai/gpt-5.3-codex", - } - } - }, - steps: [ - { - stepKey: "impl_auth", - title: "Implement auth flow", - stepIndex: 0, - metadata: { - stepType: "implementation", - phaseKey: "implementation", - phaseName: "Implementation", - phasePosition: 1, - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validator must pass before moving on", - evidence: [], - maxRetries: 2 - } - } - } - ] - }); - const implStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl_auth"); - if (!implStep) throw new Error("Missing implementation step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: implStep.id, - ownerId: "owner" - }); - - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Auth flow implemented.", - outputs: { - filesChanged: ["src/auth.ts"], - testsRun: { passed: 4, failed: 0, skipped: 1 } - }, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - - const steps = fixture.service.listSteps(started.run.id); - const validators = steps.filter((step) => { - const meta = (step.metadata ?? {}) as Record<string, unknown>; - return meta.autoSpawnedValidation === true && meta.targetStepId === implStep.id; - }); - expect(validators).toHaveLength(1); - expect(validators[0]?.dependencyStepIds).toContain(implStep.id); - const validatorMeta = (validators[0]?.metadata ?? {}) as Record<string, unknown>; - const validatorContract = (validatorMeta.validationContract ?? {}) as Record<string, unknown>; - expect(validatorMeta.targetStepKey).toBe("impl_auth"); - expect(validatorMeta.phaseKey).toBe("implementation"); - expect(validatorMeta.phaseName).toBe("Implementation"); - expect(validatorContract.required).toBe(true); - expect(validatorContract.tier).toBe("dedicated"); - - const runtimeEvents = fixture.service.listRuntimeEvents({ runId: started.run.id, limit: 100 }); - expect(runtimeEvents.some((event) => { - if (event.eventType !== "validation_contract_unfulfilled") return false; - const payload = (event.payload ?? {}) as Record<string, unknown>; - return payload.stepKey === "impl_auth"; - })).toBe(true); - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }); - expect(timeline.some((event) => event.eventType === "validation_auto_spawned")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("does not create duplicate auto-spawned validator for the same target step", async () => { - const fixture = await createFixture(); - try { - const phaseCard = { - id: "phase-implementation", - phaseKey: "implementation", - name: "Implementation", - description: "Build", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true, criteria: "Validator must pass before moving on" }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }; - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [phaseCard] }, - phaseRuntime: { currentPhaseKey: "implementation", currentPhaseName: "Implementation" } - }, - steps: [ - { - stepKey: "impl_auth", - title: "Implement auth flow", - stepIndex: 0, - metadata: { - stepType: "implementation", - phaseKey: "implementation", - phaseName: "Implementation", - phasePosition: 1, - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validator must pass before moving on", - evidence: [], - maxRetries: 2 - } - } - } - ] - }); - const implStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "impl_auth"); - if (!implStep) throw new Error("Missing implementation step"); - fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "validate_impl_auth", - title: "Validate existing impl step", - stepIndex: 1, - dependencyStepKeys: ["impl_auth"], - metadata: { - stepType: "validation", - autoSpawnedValidation: true, - targetStepId: implStep.id, - targetStepKey: "impl_auth", - phaseKey: "implementation", - phaseName: "Implementation", - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validator must pass before moving on", - evidence: [], - maxRetries: 2 - } - } - } - ] - }); - - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: implStep.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Auth flow implemented.", - outputs: {}, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - - const steps = fixture.service.listSteps(started.run.id); - const validators = steps.filter((step) => { - const meta = (step.metadata ?? {}) as Record<string, unknown>; - return meta.autoSpawnedValidation === true && meta.targetStepId === implStep.id; - }); - expect(validators).toHaveLength(1); - } finally { - fixture.dispose(); - } - }); - - it("supersedes ready auto-spawned validators when the target already has a passing validation report", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - autopilot: { - enabled: true, - executorKind: "opencode", - ownerId: "autopilot-owner", - parallelismCap: 1 - } - }, - steps: [ - { - stepKey: "test-npm", - title: "Run npm test", - stepIndex: 0, - laneId: fixture.laneId, - executorKind: "opencode", - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validate completed tests", - evidence: [], - maxRetries: 2 - } - } - } - ] - }); - const targetStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "test-npm"); - if (!targetStep) throw new Error("Missing target step"); - const validatedAt = "2026-03-04T00:10:00.000Z"; - fixture.db.run( - ` - update orchestrator_steps - set status = 'succeeded', - metadata_json = ?, - started_at = coalesce(started_at, ?), - completed_at = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [ - JSON.stringify({ - ...(targetStep.metadata ?? {}), - validationState: "pass", - validationPassedAt: validatedAt - }), - validatedAt, - validatedAt, - validatedAt, - targetStep.id, - fixture.projectId - ] - ); - fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "validate_test-npm", - title: "Validate: Run npm test", - stepIndex: 1, - laneId: fixture.laneId, - dependencyStepKeys: ["test-npm"], - executorKind: "opencode", - metadata: { - stepType: "validation", - autoSpawnedValidation: true, - targetStepId: targetStep.id, - targetStepKey: targetStep.stepKey, - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validate completed tests", - evidence: [], - maxRetries: 2 - } - } - } - ] - }); - - const startedAttempts = await fixture.service.startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "test_target_validation_passed" - }); - - expect(startedAttempts).toBe(0); - expect(fixture.service.listAttempts({ runId: started.run.id })).toHaveLength(0); - expect(fixture.ptyCreateCalls).toHaveLength(0); - const validator = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "validate_test-npm"); - expect(validator?.status).toBe("superseded"); - expect((validator?.metadata as Record<string, unknown>)?.supersededReason).toBe("target_validation_already_passed"); - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }); - expect(timeline.some((event) => event.eventType === "validation_auto_step_superseded")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("heals failed auto-spawned validators after the target receives a passing validation report", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "test-npm", - title: "Run npm test", - stepIndex: 0, - laneId: fixture.laneId, - metadata: { - validationContract: { - level: "step", - tier: "dedicated", - required: true, - criteria: "Validate completed tests", - evidence: [], - maxRetries: 2 - }, - validationState: "pass", - validationPassedAt: "2026-03-04T00:10:00.000Z" - } - } - ] - }); - const targetStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "test-npm"); - if (!targetStep) throw new Error("Missing target step"); - fixture.db.run( - `update orchestrator_steps set status = 'succeeded', completed_at = ?, updated_at = ? where id = ? and project_id = ?`, - ["2026-03-04T00:10:00.000Z", "2026-03-04T00:10:00.000Z", targetStep.id, fixture.projectId] - ); - const [validator] = fixture.service.addSteps({ - runId: started.run.id, - steps: [ - { - stepKey: "validate_test-npm", - title: "Validate: Run npm test", - stepIndex: 1, - laneId: fixture.laneId, - dependencyStepKeys: ["test-npm"], - executorKind: "opencode", - metadata: { - stepType: "validation", - autoSpawnedValidation: true, - targetStepId: targetStep.id, - targetStepKey: targetStep.stepKey - } - } - ] - }); - if (!validator) throw new Error("Missing validator step"); - fixture.db.run( - ` - insert into orchestrator_chat_threads( - id, project_id, mission_id, thread_type, title, run_id, step_id, - step_key, attempt_id, session_id, lane_id, status, unread_count, - metadata_json, created_at, updated_at - ) values (?, ?, ?, 'worker', ?, ?, ?, ?, null, null, ?, 'active', 1, null, ?, ?) - `, - [ - "worker-thread-validate-test-npm", - fixture.projectId, - fixture.missionId, - "Worker: validate_test-npm", - started.run.id, - validator.id, - validator.stepKey, - fixture.laneId, - "2026-03-04T00:11:00.000Z", - "2026-03-04T00:11:00.000Z" - ] - ); - fixture.db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where id = ? and project_id = ?`, - ["2026-03-04T00:11:00.000Z", "2026-03-04T00:11:00.000Z", validator.id, fixture.projectId] - ); - - fixture.service.tick({ runId: started.run.id }); - - const healed = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "validate_test-npm"); - expect(healed?.status).toBe("superseded"); - expect((healed?.metadata as Record<string, unknown>)?.targetValidationAlreadyPassed).toBe(true); - const thread = fixture.db.get<{ status: string }>( - `select status from orchestrator_chat_threads where id = ?`, - ["worker-thread-validate-test-npm"] - ); - expect(thread?.status).toBe("closed"); - } finally { - fixture.dispose(); - } - }); - - it("reconciles active worker threads for terminal steps during tick", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "validate-output", - title: "Validate output", - stepIndex: 0, - laneId: fixture.laneId, - metadata: { stepType: "validation" } - } - ] - }); - const step = fixture.service.listSteps(started.run.id).find((entry) => entry.stepKey === "validate-output"); - if (!step) throw new Error("Missing validation step"); - fixture.db.run( - `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where id = ? and project_id = ?`, - ["2026-03-04T00:12:00.000Z", "2026-03-04T00:12:00.000Z", step.id, fixture.projectId] - ); - fixture.db.run( - ` - insert into orchestrator_chat_threads( - id, project_id, mission_id, thread_type, title, run_id, step_id, - step_key, attempt_id, session_id, lane_id, status, unread_count, - metadata_json, created_at, updated_at - ) values (?, ?, ?, 'worker', ?, ?, ?, ?, null, null, ?, 'active', 1, null, ?, ?) - `, - [ - "worker-thread-validate-output", - fixture.projectId, - fixture.missionId, - "Worker: validate-output", - started.run.id, - step.id, - step.stepKey, - fixture.laneId, - "2026-03-04T00:12:00.000Z", - "2026-03-04T00:12:00.000Z" - ] - ); - - fixture.service.tick({ runId: started.run.id }); - - const thread = fixture.db.get<{ status: string }>( - `select status from orchestrator_chat_threads where id = ?`, - ["worker-thread-validate-output"] - ); - expect(thread?.status).toBe("closed"); - } finally { - fixture.dispose(); - } - }); - - it("emits self-check reminder message for required self-tier validation when pass is missing", async () => { - const fixture = await createFixture(); - try { - const phaseCard = { - id: "phase-testing", - phaseKey: "testing", - name: "Testing", - description: "Test", - instructions: "", - model: { provider: "openai", modelId: "openai/gpt-5.3-codex" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "self", required: true, criteria: "Coordinator must validate test results" }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", - }; - const started = fixture.service.startRun({ - missionId: fixture.missionId, - metadata: { - phaseConfiguration: { selectedPhases: [phaseCard] }, - phaseRuntime: { currentPhaseKey: "testing", currentPhaseName: "Testing" } - }, - steps: [ - { - stepKey: "test_auth", - title: "Run auth tests", - stepIndex: 0, - metadata: { - stepType: "test", - taskType: "test", - phaseKey: "testing", - phaseName: "Testing", - phasePosition: 2, - validationContract: { - level: "step", - tier: "self", - required: true, - criteria: "Coordinator must validate test results", - evidence: [], - maxRetries: 2 - } - } - } - ] - }); - const testStep = fixture.service.listSteps(started.run.id).find((step) => step.stepKey === "test_auth"); - if (!testStep) throw new Error("Missing test step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: testStep.id, - ownerId: "owner" - }); - await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Auth tests completed.", - outputs: { - testsRun: { passed: 10, failed: 0, skipped: 0 } - }, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - - const runtimeEvents = fixture.service.listRuntimeEvents({ runId: started.run.id, limit: 100 }); - expect(runtimeEvents.some((event) => { - if (event.eventType !== "validation_self_check_reminder") return false; - const payload = (event.payload ?? {}) as Record<string, unknown>; - return payload.audience === "coordinator" && String(payload.message ?? "").includes("requires self-validation"); - })).toBe(true); - expect(runtimeEvents.some((event) => event.eventType === "validation_contract_unfulfilled")).toBe(true); - - const timeline = fixture.service.listTimeline({ runId: started.run.id, limit: 100 }); - expect(timeline.some((event) => event.eventType === "validation_self_check_reminder")).toBe(true); - } finally { - fixture.dispose(); - } - }); - - it("validates reflection input strictly and rejects invalid timestamps", async () => { - const fixture = await createFixture(); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "impl", title: "Implement", stepIndex: 0 }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Expected step"); - - expect(() => - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: started.run.id, - stepId: step.id, - agentRole: "implementer", - phase: "development", - signalType: "idea", - observation: "Need better local iteration loop", - recommendation: "Add focused test command", - context: "Editing auth handler", - occurredAt: "not-a-date" - }) - ).toThrowError(ReflectionValidationError); - expect(() => - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: started.run.id, - stepId: step.id, - agentRole: "implementer", - phase: "development", - signalType: "idea", - observation: "Need better local iteration loop", - recommendation: "Add focused test command", - context: "Editing auth handler", - occurredAt: "2026-03-05 00:00:00" - }) - ).toThrowError(ReflectionValidationError); - expect(() => - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: started.run.id, - stepId: step.id, - agentRole: "implementer", - phase: "development", - signalType: "idea", - observation: "Need better local iteration loop", - recommendation: "", - context: "Editing auth handler", - occurredAt: "2026-03-05T00:00:00.000Z" - }) - ).toThrowError(ReflectionValidationError); - } finally { - fixture.dispose(); - } - }); - - it("rejects reflection scope mismatches and persists DB+ledger on valid writes", async () => { - const fixture = await createFixture(); - try { - const runA = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "a", title: "A", stepIndex: 0 }] - }); - const runB = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "b", title: "B", stepIndex: 0 }] - }); - const stepB = fixture.service.listSteps(runB.run.id)[0]; - if (!stepB) throw new Error("Expected runB step"); - - expect(() => - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: runA.run.id, - stepId: stepB.id, - agentRole: "implementer", - phase: "development", - signalType: "frustration", - observation: "Scope mismatch", - recommendation: "Use correct step scope", - context: "unit test", - occurredAt: "2026-03-05T01:00:00.000Z" - }) - ).toThrowError(ReflectionValidationError); - - const stepA = fixture.service.listSteps(runA.run.id)[0]; - if (!stepA) throw new Error("Expected runA step"); - const reflection = fixture.service.addReflection({ - missionId: fixture.missionId, - runId: runA.run.id, - stepId: stepA.id, - agentRole: "implementer", - phase: "development", - signalType: "frustration", - observation: "Typecheck is slow", - recommendation: "Use incremental mode", - context: "editing foo.ts", - occurredAt: "2026-03-05T01:05:00.000Z" - }); - const stored = fixture.service.listReflections({ runId: runA.run.id, limit: 10 }); - expect(stored.some((entry) => entry.id === reflection.id)).toBe(true); - - const ledgerPath = path.join(fixture.projectRoot, ".ade", "reflections", `${fixture.missionId}.jsonl`); - const ledgerText = fs.readFileSync(ledgerPath, "utf8"); - expect(ledgerText).toContain(reflection.id); - expect(ledgerText).toContain("\"signalType\":\"frustration\""); - } finally { - fixture.dispose(); - } - }); - - it("generates deterministic idempotent retrospectives, trends, and cancel-path artifacts", async () => { - const fixture = await createFixture(); - try { - const now = "2026-03-05T01:20:00.000Z"; - fixture.db.run( - ` - insert into missions( - id, project_id, lane_id, title, prompt, status, priority, execution_mode, target_machine_id, - outcome_summary, last_error, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - "mission-2", - fixture.projectId, - fixture.laneId, - "Mission 2", - "Second mission", - "queued", - "normal", - "local", - null, - null, - null, - null, - now, - now, - null, - null - ] - ); - - const first = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "first", title: "First", stepIndex: 0 }] - }); - const firstStep = fixture.service.listSteps(first.run.id)[0]; - if (!firstStep) throw new Error("Expected first step"); - const firstAttempt = await fixture.service.startAttempt({ runId: first.run.id, stepId: firstStep.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ - attemptId: firstAttempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "done", - outputs: {}, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: first.run.id, - stepId: firstStep.id, - attemptId: firstAttempt.id, - agentRole: "validator", - phase: "validation", - signalType: "frustration", - observation: "Slow tests", - recommendation: "Parallelize tests", - context: "running integration suite", - occurredAt: "2026-03-05T01:21:00.000Z" - }); - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: first.run.id, - stepId: firstStep.id, - attemptId: firstAttempt.id, - agentRole: "validator", - phase: "validation", - signalType: "frustration", - observation: "Flaky network", - recommendation: "Stabilize test network fixtures", - context: "integration setup", - occurredAt: "2026-03-05T01:21:30.000Z" - }); - fixture.service.addReflection({ - missionId: fixture.missionId, - runId: first.run.id, - stepId: firstStep.id, - attemptId: firstAttempt.id, - agentRole: "validator", - phase: "validation", - signalType: "frustration", - observation: "Tooling drift", - recommendation: "Pin shared tooling versions", - context: "worker bootstrap", - occurredAt: "2026-03-05T01:21:45.000Z" - }); - fixture.service.finalizeRun({ runId: first.run.id, force: true }); - const firstRetro = fixture.service.generateRunRetrospective({ runId: first.run.id }); - expect(firstRetro?.id).toBe(`retro:${first.run.id}`); - - const second = fixture.service.startRun({ - missionId: "mission-2", - steps: [{ stepKey: "second", title: "Second", stepIndex: 0 }] - }); - const secondStep = fixture.service.listSteps(second.run.id)[0]; - if (!secondStep) throw new Error("Expected second step"); - const secondAttempt = await fixture.service.startAttempt({ runId: second.run.id, stepId: secondStep.id, ownerId: "owner" }); - await fixture.service.completeAttempt({ - attemptId: secondAttempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "done", - outputs: {}, - warnings: [], - sessionId: null, - trackedSession: false - } - }); - fixture.service.addReflection({ - missionId: "mission-2", - runId: second.run.id, - stepId: secondStep.id, - attemptId: secondAttempt.id, - agentRole: "validator", - phase: "validation", - signalType: "frustration", - observation: "Slow tests", - recommendation: "Parallelize tests", - context: "first pass", - occurredAt: "2026-03-05T01:25:00.000Z" - }); - fixture.service.addReflection({ - missionId: "mission-2", - runId: second.run.id, - stepId: secondStep.id, - attemptId: secondAttempt.id, - agentRole: "validator", - phase: "validation", - signalType: "frustration", - observation: "Slow tests", - recommendation: "Parallelize tests", - context: "second pass", - occurredAt: "2026-03-05T01:26:00.000Z" - }); - fixture.service.addReflection({ - missionId: "mission-2", - runId: second.run.id, - stepId: secondStep.id, - attemptId: secondAttempt.id, - agentRole: "validator", - phase: "validation", - signalType: "frustration", - observation: "Tooling drift", - recommendation: "Pin shared tooling versions", - context: "worker bootstrap", - occurredAt: "2026-03-05T01:26:15.000Z" - }); - fixture.service.finalizeRun({ runId: second.run.id, force: true }); - const secondRetro = fixture.service.generateRunRetrospective({ runId: second.run.id }); - const secondRetroAgain = fixture.service.generateRunRetrospective({ runId: second.run.id }); - expect(secondRetro?.id).toBe(`retro:${second.run.id}`); - expect(secondRetroAgain?.id).toBe(secondRetro?.id); - expect(secondRetroAgain?.generatedAt).toBe(secondRetro?.generatedAt); - expect(secondRetro?.changelog.some((entry) => entry.status === "worsened")).toBe(true); - expect(secondRetro?.changelog.some((entry) => entry.status === "resolved")).toBe(true); - expect(secondRetro?.changelog.some((entry) => entry.status === "still_open")).toBe(true); - const trendsBefore = fixture.service.listRetrospectiveTrends({ runId: second.run.id, limit: 100 }); - const trendsAfter = fixture.service.listRetrospectiveTrends({ runId: second.run.id, limit: 100 }); - expect(trendsBefore.length).toBeGreaterThan(0); - expect(trendsAfter.length).toBe(trendsBefore.length); - expect(trendsBefore.some((entry) => entry.status === "worsened")).toBe(true); - expect(trendsBefore.some((entry) => entry.status === "resolved")).toBe(true); - expect(trendsBefore.some((entry) => entry.status === "still_open")).toBe(true); - expect(trendsBefore.every((entry) => entry.sourceRetrospectiveId.length > 0)).toBe(true); - expect(trendsBefore.every((entry) => entry.sourceMissionId.length > 0)).toBe(true); - expect(trendsBefore.every((entry) => entry.sourceRunId.length > 0)).toBe(true); - - const canceled = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "cancel", title: "Cancel", stepIndex: 0 }] - }); - fixture.service.cancelRun({ runId: canceled.run.id, reason: "user canceled" }); - const canceledRetro = fixture.service.generateRunRetrospective({ runId: canceled.run.id }); - expect(canceledRetro?.id).toBe(`retro:${canceled.run.id}`); - } finally { - fixture.dispose(); - } - }); - -}); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts deleted file mode 100644 index 58c70be4c..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ /dev/null @@ -1,13264 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import type { - ConflictProposal, - ConflictProposalPreview, - ExternalConflictResolverProvider, - FinalizeRunArgs, - FinalizeRunResult, - MissionExecutionPolicy, - MissionStepHandoff, - MissionRetrospective, - OrchestratorRetrospectivePatternStat, - OrchestratorRetrospectiveTrend, - OrchestratorAttempt, - OrchestratorAttemptResultEnvelope, - OrchestratorAttemptStatus, - OrchestratorClaim, - OrchestratorClaimScope, - OrchestratorClaimState, - OrchestratorContextPolicyProfile, - OrchestratorContextProfileId, - OrchestratorContextSnapshot, - OrchestratorContextSnapshotCursor, - OrchestratorDocsRef, - OrchestratorErrorClass, - OrchestratorExecutorKind, - OrchestratorGateEntry, - OrchestratorGateReport, - OrchestratorGateStatus, - OrchestratorRun, - OrchestratorRunGraph, - OrchestratorRunStatus, - OrchestratorStep, - OrchestratorStepStatus, - OrchestratorRuntimeBusEvent, - OrchestratorReflectionEntry, - OrchestratorRuntimeEventType, - OrchestratorTeamRuntimeState, - OrchestratorTimelineEvent, - PrepareResolverSessionArgs, - PtyCreateArgs, - RunCompletionEvaluation, - StartOrchestratorRunArgs, - StartOrchestratorRunStepInput, - RecoveryLoopPolicy, - RecoveryLoopIteration, - RecoveryLoopState, - OrchestratorContextView, - ContextViewPolicy, - OrchestratorWorkerRole, - RoleIsolationValidation, - IntegrationPrPolicy, - MissionLevelSettings, - PhaseCard, - TerminalToolType, - OrchestratorArtifact, - OrchestratorArtifactKind, - OrchestratorWorkerCheckpoint, - FanOutDecision, - AgentChatExecutionMode, - AgentChatPermissionMode, - AgentChatEventEnvelope, - WorkerResultReport, -} from "../../../shared/types"; -import { - DEFAULT_RECOVERY_LOOP_POLICY, - DEFAULT_CONTEXT_VIEW_POLICIES, - DEFAULT_ROLE_ISOLATION_RULES, -} from "./orchestratorConstants"; -import { evaluateRunCompletion, evaluateRunCompletionFromPhases, validateRunCompletion, DEFAULT_EXECUTION_POLICY } from "./executionPolicy"; -import { - createProviderOrchestratorAdapter, - cleanupWorkerRuntimeFiles, - nodeWorkerLaunch, - writeWorkerLaunchFile, - writeWorkerPromptFile, -} from "./providerOrchestratorAdapter"; -import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; -import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils"; -import { resolveCodexExecutable } from "../ai/codexExecutable"; -import { runGit } from "../git/git"; -import type { AdeDb, SqlValue } from "../state/kvDb"; -import type { createPtyService } from "../pty/ptyService"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createPrService } from "../prs/prService"; -import type { AiTaskType } from "../ai/aiIntegrationService"; -import { asRecord, nowIso, parseJsonRecord, TERMINAL_STEP_STATUSES, filterExecutionSteps, isDisplayOnlyTaskStep, buildQuestionThreadLink } from "./orchestratorContext"; -import { parseNumericDependencyIndices } from "./missionLifecycle"; -import { getMissionStateDocumentPath } from "./missionStateDoc"; -import { buildFullPrompt, shellEscapeArg } from "./baseOrchestratorAdapter"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import { classifyWorkerExecutionPath, resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { - deriveAgentChatTranscriptSummary, - hasMaterialWorkerChatEvent, - hasWorkerChatLifecycleEvent, - parseAgentChatTranscript, -} from "../../../shared/chatTranscript"; -import { isWorkerBootstrapNoiseLine } from "../../../shared/workerRuntimeNoise"; -import { deriveSessionSummaryFromText } from "../shared/transcriptInsights"; -import { - type RunRow, type StepRow, type AttemptRow, type ClaimRow, - type ContextSnapshotRow, type HandoffRow, type TimelineRow, - type RuntimeEventRow, type GateReportRow, type ArtifactRow, - DEFAULT_CONTEXT_POLICY, - TERMINAL_RUN_STATUSES, RETRYABLE_ERROR_CLASSES, - MAX_TIMELINE_LIMIT, GATE_THRESHOLDS, - normalizeIsoTimestamp, normalizeRunStatus, - normalizeExecutorKind, - normalizeJoinPolicy, - normalizeRuntimeEventType, normalizeProfileId, normalizeTerminalSessionStatus, - parseArray, isExecutionPolicyRecord, - asBool, asIntInRange, asNumberInRange, asPositiveNumberOrNull, - toRun, toStep, toAttempt, toClaim, toContextSnapshot, toHandoff, - toArtifact, toTimelineEvent, toRuntimeEvent, toGateReport, - normalizeEnvelope, - type StepGraphValidationStep, - normalizeDependencyStepKeys, validateStepGraphIntegrity, - branchNameFromRef, clipText, - classifyBlockingWarnings, -} from "./orchestratorQueries"; -import { - type ResolvedOrchestratorRuntimeConfig, - DEFAULT_ORCHESTRATOR_RUNTIME_CONFIG, - resolveStepPolicy, resolveContextPolicy, - parseAutopilotConfig, parseStepPolicyFromMetadata, - readyStepOrderComparator, - normalizeRepoRelativePath, normalizeFileClaimScopeValue, - doesFileClaimMatchPath, doFileClaimsOverlap, - readDocPaths, -} from "./stepPolicyResolver"; -import { normalizeAgentRuntimeFlags } from "./teamRuntimeConfig"; -import { mapPermissionToCodex, mergeMissionPermissionConfig, normalizeMissionPermissions, providerPermissionsToLegacyConfig, mapPermissionToInProcess } from "./permissionMapping"; -import type { MissionPermissionConfig, MissionProviderPermissions } from "../../../shared/types/missions"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { - hasResolvedPlanningQuestionIntervention, - phaseRequiresPlanningQuestionBeforeExit, -} from "./planningQuestionPolicy"; - -type PlannerQuestionReasonCode = "planner_natural_question" | "planner_required_question_missing"; - -// Row types, StepPolicy, and other extracted types are imported from -// ./orchestratorQueries and ./stepPolicyResolver - -type CreateSnapshotResult = { - snapshotId: string; - cursor: OrchestratorContextSnapshotCursor; - laneExport: { content: string; truncated: boolean } | null; - projectExport: { content: string; truncated: boolean }; - docsRefs: OrchestratorDocsRef[]; - fullDocs: Array<{ path: string; content: string; truncated: boolean }>; -}; - -// ResolvedOrchestratorRuntimeConfig is imported from ./stepPolicyResolver - -export type OrchestratorEvent = { - type: - | "orchestrator-run-updated" - | "orchestrator-step-updated" - | "orchestrator-attempt-updated" - | "orchestrator-claim-updated"; - runId?: string; - stepId?: string; - attemptId?: string; - claimId?: string; - at: string; - reason: string; -}; - -type ManagedWorkerLaunch = { - prompt: string; - displayText: string; - reasoningEffort?: string | null; - executionMode?: AgentChatExecutionMode | null; - permissionMode?: AgentChatPermissionMode | null; -}; - -const VALID_PROJECT_PROVIDER_PERMISSION_MODES = new Set<AgentChatPermissionMode>([ - "default", - "plan", - "edit", - "full-auto", - "config-toml", -]); -const STARTUP_VERIFICATION_GRACE_MS = 60_000; - -function projectPermissionConfigFromRecord(permissions: Record<string, unknown> | null | undefined): MissionPermissionConfig { - const projectPerms: MissionPermissionConfig = {}; - if (!permissions) return projectPerms; - - const cli = asRecord(permissions.cli); - if (cli) { - projectPerms.cli = { - ...(typeof cli.mode === "string" ? { mode: cli.mode as MissionPermissionConfig["cli"] extends { mode?: infer M } ? M : never } : {}), - ...(typeof cli.sandboxPermissions === "string" ? { sandboxPermissions: cli.sandboxPermissions as "read-only" | "workspace-write" | "danger-full-access" } : {}), - ...(Array.isArray(cli.writablePaths) ? { writablePaths: cli.writablePaths.filter((e): e is string => typeof e === "string").map(e => e.trim()).filter(e => e.length > 0) } : {}), - ...(Array.isArray(cli.allowedTools) ? { allowedTools: cli.allowedTools.filter((e): e is string => typeof e === "string").map(e => e.trim()).filter(e => e.length > 0) } : {}), - }; - } - - const inProc = asRecord(permissions.inProcess); - if (inProc) { - const mode = typeof inProc.mode === "string" ? inProc.mode : ""; - if (mode === "plan" || mode === "edit" || mode === "full-auto") { - projectPerms.inProcess = { mode }; - } - } - - const providers = asRecord(permissions.providers); - if (providers) { - const providerPerms: NonNullable<MissionPermissionConfig["providers"]> = {}; - const maybeMode = (key: "claude" | "codex" | "cursor" | "droid" | "opencode") => { - const value = typeof providers[key] === "string" ? providers[key].trim() : ""; - if (VALID_PROJECT_PROVIDER_PERMISSION_MODES.has(value as AgentChatPermissionMode)) { - providerPerms[key] = value as AgentChatPermissionMode; - } - }; - maybeMode("claude"); - maybeMode("codex"); - maybeMode("cursor"); - maybeMode("droid"); - maybeMode("opencode"); - const codexSandbox = typeof providers.codexSandbox === "string" ? providers.codexSandbox.trim() : ""; - if (codexSandbox === "read-only" || codexSandbox === "workspace-write" || codexSandbox === "danger-full-access") { - providerPerms.codexSandbox = codexSandbox; - } - const writablePaths = Array.isArray(providers.writablePaths) - ? providers.writablePaths.filter((e): e is string => typeof e === "string").map(e => e.trim()).filter(e => e.length > 0) - : []; - if (writablePaths.length) providerPerms.writablePaths = writablePaths; - const allowedTools = Array.isArray(providers.allowedTools) - ? providers.allowedTools.filter((e): e is string => typeof e === "string").map(e => e.trim()).filter(e => e.length > 0) - : []; - if (allowedTools.length) providerPerms.allowedTools = allowedTools; - if (Object.keys(providerPerms).length) projectPerms.providers = providerPerms; - } - - return projectPerms; -} - -export type OrchestratorExecutorStartResult = - | { - status: "accepted"; - sessionId?: string | null; - metadata?: Record<string, unknown> | null; - launch?: ManagedWorkerLaunch | null; - } - | { - status: "completed"; - result: OrchestratorAttemptResultEnvelope; - metadata?: Record<string, unknown> | null; - } - | { - status: "failed"; - errorClass?: OrchestratorErrorClass; - errorMessage: string; - metadata?: Record<string, unknown> | null; - }; - -export type OrchestratorExecutorStartArgs = { - run: OrchestratorRun; - step: OrchestratorStep; - attempt: OrchestratorAttempt; - /** All steps in the run, for building a compact plan view in the worker prompt. */ - allSteps: OrchestratorStep[]; - contextProfile: OrchestratorContextPolicyProfile; - laneExport: { content: string; truncated: boolean } | null; - projectExport: { content: string; truncated: boolean }; - docsRefs: OrchestratorDocsRef[]; - fullDocs: Array<{ path: string; content: string; truncated: boolean }>; - createTrackedSession: (args: Omit<PtyCreateArgs, "tracked"> & { tracked?: boolean }) => Promise<{ ptyId: string; sessionId: string }>; - permissionConfig?: { - cli?: { - mode?: "read-only" | "edit" | "full-auto"; - sandboxPermissions?: "read-only" | "workspace-write" | "danger-full-access"; - writablePaths?: string[]; - allowedTools?: string[]; - }; - inProcess?: { - mode?: "plan" | "edit" | "full-auto"; - }; - /** Per-provider permission modes. When present, adapters - * should prefer this over the legacy cli/inProcess fields. */ - _providers?: MissionProviderPermissions; - }; - /** Checkpoint content from a previous interrupted attempt's worker checkpoint file. */ - previousCheckpoint?: string; - /** Summary/error from the previous attempt on the same step (for retry context). */ - previousAttemptSummary?: string; -}; - -export type OrchestratorExecutorAdapter = { - kind: OrchestratorExecutorKind; - requiresLaneId?: boolean; - start: (args: OrchestratorExecutorStartArgs) => Promise<OrchestratorExecutorStartResult>; -}; - -// Constants (CONTEXT_PROFILES, TERMINAL_RUN_STATUSES, etc.) and -// DEFAULT_ORCHESTRATOR_RUNTIME_CONFIG are imported from -// ./orchestratorQueries and ./stepPolicyResolver - -// Normalizer functions are imported from ./orchestratorQueries - -// StepGraphValidationStep, normalizeDependencyStepKeys, validateStepGraphIntegrity -// are imported from ./orchestratorQueries - -// Row-to-domain mappers, normalizeEnvelope, sha256, clipText, -// file claim helpers, readDocPaths, resolveStepPolicy, resolveContextPolicy, -// parseAutopilotConfig, parseStepPolicyFromMetadata, parseStepAIPriority, -// readyStepOrderComparator are imported from ./orchestratorQueries and ./stepPolicyResolver - -function sha256(data: Buffer | string): string { - return createHash("sha256").update(data).digest("hex"); -} - -function getWorkerCheckpointPath(worktreePath: string, stepKey: string): string { - const sanitizedStepKey = stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - return path.join(worktreePath, ".ade", "checkpoints", `${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`); -} - -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"; - required: boolean; - criteria: string; - evidence: string[]; - maxRetries: number; -}; - -function normalizeValidationTier(value: unknown): "none" | "self" | "dedicated" { - const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (raw === "dedicated") return "dedicated"; - if (raw === "self" || raw === "self-check") return "self"; - return "none"; -} - -function parseValidationContract(value: unknown): NormalizedValidationContract | null { - const record = asRecord(value); - if (!record) return null; - const levelRaw = typeof record.level === "string" ? record.level.trim().toLowerCase() : ""; - const level = - levelRaw === "step" || levelRaw === "milestone" || levelRaw === "mission" - ? levelRaw - : "step"; - const tier = normalizeValidationTier(record.tier); - if (tier === "none") return null; - const criteria = typeof record.criteria === "string" ? record.criteria.trim() : ""; - if (!criteria.length) return null; - const evidence = Array.isArray(record.evidence) - ? record.evidence - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : []; - const maxRetriesRaw = Number(record.maxRetries); - const maxRetries = Number.isFinite(maxRetriesRaw) - ? Math.max(0, Math.min(10, Math.floor(maxRetriesRaw))) - : 2; - return { - level, - tier, - required: record.required !== false, - criteria, - evidence, - maxRetries, - }; -} - -function hasPassingValidation(metadata: Record<string, unknown> | null): boolean { - if (!metadata) return false; - const validationState = typeof metadata.validationState === "string" - ? metadata.validationState.trim().toLowerCase() - : ""; - if (validationState === "pass" || validationState === "passed" || validationState === "success" || validationState === "succeeded") return true; - const lastValidationReport = asRecord(metadata.lastValidationReport); - const reportVerdict = typeof lastValidationReport?.verdict === "string" - ? lastValidationReport.verdict.trim().toLowerCase() - : ""; - if (reportVerdict === "pass" || reportVerdict === "passed" || reportVerdict === "success" || reportVerdict === "succeeded") return true; - const validationPassedAt = typeof metadata.validationPassedAt === "string" ? metadata.validationPassedAt.trim() : ""; - return validationPassedAt.length > 0; -} - -function getAutoSpawnedValidationTargetStepId(metadata: Record<string, unknown> | null): string | null { - if (!metadata || metadata.autoSpawnedValidation !== true) return null; - const targetStepId = typeof metadata.targetStepId === "string" ? metadata.targetStepId.trim() : ""; - return targetStepId.length > 0 ? targetStepId : null; -} - -type ReflectionAddInput = { - missionId: string; - runId: string; - stepId?: string | null; - attemptId?: string | null; - agentRole: string; - phase: string; - signalType: "wish" | "frustration" | "idea" | "pattern" | "limitation"; - observation: string; - recommendation: string; - context: string; - occurredAt?: string; -}; - -type ReflectionValidationCode = - | "mission_id_required" - | "run_id_required" - | "agent_role_required" - | "phase_required" - | "signal_type_invalid" - | "observation_required" - | "recommendation_required" - | "context_required" - | "occurred_at_invalid" - | "run_not_found" - | "run_mission_mismatch" - | "step_not_found" - | "step_run_mismatch" - | "attempt_not_found" - | "attempt_run_mismatch" - | "attempt_step_mismatch"; - -export class ReflectionValidationError extends Error { - readonly code: ReflectionValidationCode; - readonly details: Record<string, unknown> | null; - - constructor(code: ReflectionValidationCode, message: string, details?: Record<string, unknown>) { - super(message); - this.name = "ReflectionValidationError"; - this.code = code; - this.details = details ?? null; - } -} - -type PainPointCounter = Map<string, { label: string; count: number }>; - -const RETROSPECTIVE_TREND_LOOKBACK_LIMIT = 50; -const ISO_8601_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/; -const RETRY_BASE_MS = 10_000; -const RETRY_MULTIPLIER = 2; -const RETRY_MAX_MS = 300_000; -const MAX_RESTART_RECOVERY_ATTEMPTS = 3; -const RESTART_INTERRUPTION_MESSAGE_PARTS = [ - "closed during shutdown", - "orphaned by app restart", - "worker session was lost" -] as const; - -function normalizeReflectionSignalType(value: string): OrchestratorReflectionEntry["signalType"] | null { - if ( - value === "wish" || - value === "frustration" || - value === "idea" || - value === "pattern" || - value === "limitation" - ) { - return value; - } - return null; -} - -function normalizePainPointKey(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - -function isStrictIsoTimestamp(value: string): boolean { - if (!ISO_8601_TIMESTAMP_RE.test(value)) return false; - const ms = Date.parse(value); - return Number.isFinite(ms); -} - -function isRestartInterruptedAttemptFailure(input: { - errorClass?: OrchestratorErrorClass | string | null; - errorMessage?: string | null; -}): boolean { - const errorClass = String(input.errorClass ?? "").trim(); - const message = String(input.errorMessage ?? "").toLowerCase(); - if (errorClass === "resume_recovered") return true; - return RESTART_INTERRUPTION_MESSAGE_PARTS.some((part) => message.includes(part)); -} - -function parseRetrospectivePayload(payload: string | null | undefined): MissionRetrospective | null { - try { - const parsed = JSON.parse(String(payload ?? "{}")) as MissionRetrospective; - if (parsed && typeof parsed.missionId === "string" && typeof parsed.runId === "string" && parsed.missionId && parsed.runId) { - return parsed; - } - } catch { - // ignore malformed payloads - } - return null; -} - -function buildPainPointCounter(entries: string[]): PainPointCounter { - const counter: PainPointCounter = new Map(); - for (const entry of entries) { - const label = String(entry ?? "").trim(); - if (!label) continue; - const key = normalizePainPointKey(label); - if (!key) continue; - const current = counter.get(key); - if (current) { - current.count += 1; - } else { - counter.set(key, { label, count: 1 }); - } - } - return counter; -} - -function computePainTrendStatus(previousPainScore: number, currentPainScore: number): "resolved" | "still_open" | "worsened" { - if (currentPainScore <= 0) return "resolved"; - if (currentPainScore > previousPainScore) return "worsened"; - return "still_open"; -} - -function ensureReflectionLedgerDir(projectRoot: string): string { - const dir = resolveAdeLayout(projectRoot).reflectionsDir; - fs.mkdirSync(dir, { recursive: true }); - return dir; -} - -function appendReflectionLedgerEntry(projectRoot: string, missionId: string, payload: Record<string, unknown>): void { - const dir = ensureReflectionLedgerDir(projectRoot); - const filePath = path.join(dir, `${missionId}.jsonl`); - fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8"); -} - -function persistRetrospectiveArtifact(projectRoot: string, missionId: string, runId: string, payload: Record<string, unknown>): string { - const dir = path.join(ensureReflectionLedgerDir(projectRoot), "retrospectives"); - fs.mkdirSync(dir, { recursive: true }); - const filePath = path.join(dir, `${missionId}-${runId}.json`); - fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8"); - return filePath; -} - -function loadMissionStateDocCounts(projectRoot: string, runId: string): { - decisions: number; - activeIssues: number; - pendingInterventions: number; - stepOutcomes: number; -} { - const filePath = getMissionStateDocumentPath(projectRoot, runId); - if (!fs.existsSync(filePath)) { - return { decisions: 0, activeIssues: 0, pendingInterventions: 0, stepOutcomes: 0 }; - } - try { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>; - const decisions = Array.isArray(parsed.decisions) ? parsed.decisions.length : 0; - const activeIssues = Array.isArray(parsed.activeIssues) ? parsed.activeIssues.length : 0; - const pendingInterventions = Array.isArray(parsed.pendingInterventions) ? parsed.pendingInterventions.length : 0; - const stepOutcomes = Array.isArray(parsed.stepOutcomes) ? parsed.stepOutcomes.length : 0; - return { decisions, activeIssues, pendingInterventions, stepOutcomes }; - } catch { - return { decisions: 0, activeIssues: 0, pendingInterventions: 0, stepOutcomes: 0 }; - } -} - -function readUtf8Tail(filePath: string, maxBytes = 64 * 1024): string { - const stat = fs.statSync(filePath); - const size = Math.max(0, Number(stat.size) || 0); - if (size <= maxBytes) { - return fs.readFileSync(filePath, "utf8"); - } - const fd = fs.openSync(filePath, "r"); - try { - const start = Math.max(0, size - maxBytes); - const length = size - start; - const buffer = Buffer.alloc(length); - fs.readSync(fd, buffer, 0, length, start); - return buffer.toString("utf8"); - } finally { - fs.closeSync(fd); - } -} - -const RUN_METADATA_DOC_REF_LIMIT = 12; - -function isAdeInternalDocRef(ref: OrchestratorDocsRef): boolean { - const normalized = ref.path.replace(/\\/g, "/"); - return normalized === ".ade" || normalized.startsWith(".ade/") || normalized.includes("/.ade/"); -} - -function compactRuntimeCursorForRunMetadata(cursor: OrchestratorContextSnapshotCursor): Record<string, unknown> { - const docs = cursor.docs - .filter((ref) => !isAdeInternalDocRef(ref)) - .slice(0, RUN_METADATA_DOC_REF_LIMIT); - return { - ...cursor, - docs, - docsOmittedCount: Math.max(0, cursor.docs.length - docs.length), - }; -} - -function stripAnsiCodes(value: string): string { - return value.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ""); -} - -function parseJsonObjectAt(text: string, startIndex: number): Record<string, unknown> | null { - let depth = 0; - let inString = false; - let escaping = false; - for (let i = startIndex; i < text.length; i += 1) { - const ch = text[i]!; - if (inString) { - if (escaping) { - escaping = false; - } else if (ch === "\\") { - escaping = true; - } else if (ch === "\"") { - inString = false; - } - continue; - } - if (ch === "\"") { - inString = true; - continue; - } - if (ch === "{") depth += 1; - if (ch === "}") { - depth -= 1; - if (depth === 0) { - try { - const parsed = JSON.parse(text.slice(startIndex, i + 1)); - return asRecord(parsed); - } catch { - return null; - } - } - } - } - return null; -} - -function stripCodexTranscriptFooter(text: string): string { - return text.replace(/\r?\n\r?\ntokens used\r?\n[\s\S]*$/i, "").trimEnd(); -} - -function missingJsonObjectClosingBraceCount(text: string, startIndex: number): number | null { - let depth = 0; - let inString = false; - let escaping = false; - for (let i = startIndex; i < text.length; i += 1) { - const ch = text[i]!; - if (inString) { - if (escaping) { - escaping = false; - } else if (ch === "\\") { - escaping = true; - } else if (ch === "\"") { - inString = false; - } - continue; - } - if (ch === "\"") { - inString = true; - continue; - } - if (ch === "{") depth += 1; - if (ch === "}") depth -= 1; - if (depth < 0) return null; - } - if (inString || depth <= 0 || depth > 4) return null; - return depth; -} - -function parseJsonObjectAtWithMissingTrailingBraces(text: string, startIndex: number): Record<string, unknown> | null { - const candidate = stripCodexTranscriptFooter(text.slice(startIndex)).trim(); - if (!candidate.startsWith("{")) return null; - const missingBraces = missingJsonObjectClosingBraceCount(candidate, 0); - if (missingBraces == null) return null; - try { - return asRecord(JSON.parse(`${candidate}${"}".repeat(missingBraces)}`)); - } catch { - return null; - } -} - -function looksLikeWorkerResultReportPayload(value: Record<string, unknown> | null): value is Record<string, unknown> { - const plan = asRecord(value?.plan); - if (typeof plan?.markdown === "string" && plan.markdown.trim().length > 0) return true; - const summary = typeof value?.summary === "string" ? value.summary.trim() : ""; - if (!summary.length) return false; - const outcome = typeof value?.outcome === "string" ? value.outcome.trim().toLowerCase() : ""; - if (outcome === "failed" || outcome === "partial" || outcome === "succeeded" || outcome === "completed") return true; - if (Array.isArray(value?.filesChanged) || Array.isArray(value?.files_changed)) return true; - if (asRecord(value?.testsRun) || asRecord(value?.tests_run)) return true; - return false; -} - -function findLastWorkerResultReportJson( - text: string, - options: { allowMissingTrailingBraces?: boolean } = {} -): Record<string, unknown> | null { - let fencedReport: Record<string, unknown> | null = null; - const fencedJsonPattern = /```(?:json)?\s*([\s\S]*?)```/gi; - for (const match of text.matchAll(fencedJsonPattern)) { - try { - const parsed = asRecord(JSON.parse(match[1]?.trim() ?? "")); - if (looksLikeWorkerResultReportPayload(parsed)) { - fencedReport = parsed; - } - } catch { - // Ignore non-JSON fenced blocks and fall back to balanced-object recovery. - } - } - if (fencedReport) return fencedReport; - - let found: Record<string, unknown> | null = null; - for (let i = 0; i < text.length; i += 1) { - if (text[i] !== "{") continue; - const parsed = parseJsonObjectAt(text, i); - if (looksLikeWorkerResultReportPayload(parsed)) { - found = parsed; - } - } - if (found || !options.allowMissingTrailingBraces) return found; - for (let i = 0; i < text.length; i += 1) { - if (text[i] !== "{") continue; - const parsed = parseJsonObjectAtWithMissingTrailingBraces(text, i); - if (looksLikeWorkerResultReportPayload(parsed)) { - found = parsed; - } - } - return found; -} - -function stripMarkdownLinkLabel(value: string): string { - const trimmed = value.trim(); - const markdownLink = /^\[([^\]]+)\]\([^)]+\)$/.exec(trimmed); - return (markdownLink?.[1] ?? trimmed).replace(/^`|`$/g, "").trim(); -} - -function parseReportResultMarkdownSectionMap(text: string): Map<string, string> { - const sections = new Map<string, string>(); - const sectionPattern = /^###\s+(.+?)\s*\n([\s\S]*?)(?=^###\s+|(?![\s\S]))/gm; - for (const match of text.matchAll(sectionPattern)) { - const key = String(match[1] ?? "").trim().toLowerCase(); - const body = String(match[2] ?? "").trim(); - if (key.length > 0 && body.length > 0) sections.set(key, body); - } - return sections; -} - -function parseReportResultMarkdown(text: string): Record<string, unknown> | null { - const markerIndex = text.toLowerCase().lastIndexOf("## report_result"); - if (markerIndex < 0) return null; - const reportText = text.slice(markerIndex); - const sections = parseReportResultMarkdownSectionMap(reportText); - const summary = sections.get("summary")?.trim() ?? ""; - if (!summary.length) return null; - const outcomeRaw = (sections.get("outcome") ?? "").trim().toLowerCase(); - const outcome = - outcomeRaw.includes("fail") - ? "failed" - : outcomeRaw.includes("partial") - ? "partial" - : "succeeded"; - const filesChanged = (sections.get("fileschanged") ?? sections.get("files changed") ?? "") - .split(/\r?\n/) - .map((line) => line.replace(/^\s*[-*]\s*/, "").trim()) - .map(stripMarkdownLinkLabel) - .filter((line) => line.length > 0); - const testsSection = sections.get("testsrun") ?? sections.get("tests run") ?? ""; - const command = /`([^`]+)`/.exec(testsSection)?.[1]?.trim(); - const numeric = (label: string): number | undefined => { - const pattern = new RegExp(`${label}\\s*:\\s*(\\d+)`, "i"); - const match = pattern.exec(testsSection); - if (!match?.[1]) return undefined; - const value = Number(match[1]); - return Number.isFinite(value) ? value : undefined; - }; - const testsRun = testsSection.trim().length > 0 - ? { - ...(command ? { command } : {}), - ...(numeric("passed") != null ? { passed: numeric("passed") } : {}), - ...(numeric("failed") != null ? { failed: numeric("failed") } : {}), - ...(numeric("skipped") != null ? { skipped: numeric("skipped") } : {}), - raw: testsSection.trim(), - } - : null; - return { - outcome, - summary, - filesChanged, - ...(testsRun ? { testsRun } : {}), - }; -} - -function unquoteYamlishScalar(value: string): string { - const trimmed = value.trim(); - if ( - (trimmed.startsWith("\"") && trimmed.endsWith("\"")) - || (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1).replace(/\\"/g, "\"").replace(/\\'/g, "'"); - } - return trimmed; -} - -function parseYamlishStringList(reportText: string, fieldName: string): string[] | undefined { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const inline = new RegExp(`^\\s{2}${escaped}:\\s*(\\[[^\\n\\r]*\\])\\s*$`, "im").exec(reportText)?.[1]?.trim(); - if (inline === "[]") return []; - if (inline?.startsWith("[") && inline.endsWith("]")) { - return inline - .slice(1, -1) - .split(",") - .map((entry) => unquoteYamlishScalar(entry)) - .filter((entry) => entry.length > 0); - } - - const block = new RegExp(`^\\s{2}${escaped}:\\s*$\\n([\\s\\S]*?)(?=^\\s{2}\\w[\\w-]*:\\s*|^diff --git\\s|\\ncodex\\n|(?![\\s\\S]))`, "im") - .exec(reportText)?.[1] ?? ""; - if (!block.trim().length) return undefined; - return block - .split(/\r?\n/) - .map((line) => /^\s{4}-\s*(.+?)\s*$/.exec(line)?.[1] ?? "") - .map((entry) => unquoteYamlishScalar(stripMarkdownLinkLabel(entry))) - .filter((entry) => entry.length > 0); -} - -function parseCodexYamlishReportResult(text: string): Record<string, unknown> | null { - const markerMatches = [...text.matchAll(/^report_result:\s*$/gim)]; - const marker = markerMatches.at(-1); - if (!marker?.index && marker?.index !== 0) return null; - const reportText = text.slice(marker.index); - const scalar = (fieldName: string): string => { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const value = new RegExp(`^\\s{2}${escaped}:\\s*([^\\n\\r]*)$`, "im").exec(reportText)?.[1] ?? ""; - return unquoteYamlishScalar(value); - }; - - const outcome = scalar("outcome"); - const summary = scalar("summary"); - const filesChanged = parseYamlishStringList(reportText, "filesChanged"); - const planMarker = /^ {2}plan:\s*\r?\n {4}markdown:\s*\|\s*\r?\n/im.exec(reportText); - const planMarkdown = (() => { - if (!planMarker) return ""; - const start = (planMarker.index ?? 0) + planMarker[0].length; - const rest = reportText.slice(start); - const boundary = /\n {2}\w[\w-]*:\s*/.exec(rest); - const block = boundary ? rest.slice(0, boundary.index) : rest; - return block - .split(/\r?\n/) - .map((line) => { - if (line.startsWith(" ")) return line.slice(6); - if (line.trim().length === 0) return ""; - return line; - }) - .join("\n") - .trim(); - })(); - - if (!summary.length && !planMarkdown.length) return null; - return { - ...(outcome.length > 0 ? { outcome } : {}), - ...(summary.length > 0 ? { summary } : {}), - ...(filesChanged ? { filesChanged } : {}), - ...(planMarkdown.length > 0 ? { plan: { markdown: planMarkdown } } : {}), - }; -} - -function markdownFieldLabelSource(fieldName: string): string { - const labels = [ - fieldName, - fieldName.replace(/([a-z])([A-Z])/g, "$1 $2"), - ].filter((entry, index, all) => all.indexOf(entry) === index); - const labelSource = labels - .map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"); - return `(?:\`\\s*(?:${labelSource})\\s*\`|\\*\\*\\s*(?:${labelSource})\\s*:?\\s*\\*\\*|(?:${labelSource}))`; -} - -function parseBoldMarkdownField(text: string, fieldName: string): string { - const labels = [ - fieldName, - fieldName.replace(/([a-z])([A-Z])/g, "$1 $2"), - ].filter((entry, index, all) => all.indexOf(entry) === index); - const labelSource = labels - .map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"); - const pattern = new RegExp( - `^\\s*(?:[-*]\\s*)?(?:\`\\s*(?:${labelSource})\\s*\`|\\*\\*\\s*(?:${labelSource})\\s*:?\\s*\\*\\*)\\s*:?\\s*([^\\n\\r]+)`, - "im" - ); - return pattern.exec(text)?.[1]?.trim().replace(/^`|`$/g, "") ?? ""; -} - -function parseLooseMarkdownField(text: string, fieldName: string): string { - const allLabels = [ - "Outcome", - "Summary", - "Implemented", - "Accomplished", - "What I accomplished", - "What I changed", - "FilesChanged", - "Files Changed", - "TestsRun", - "Tests Run", - "Tests", - "Validation", - "Risks", - "Plan", - "PlanMarkdown", - "Plan Markdown", - "plan.markdown", - "Risk/notes for downstream", - "Handoff Summary", - ]; - const fieldSource = markdownFieldLabelSource(fieldName); - const boundarySource = allLabels - .map(markdownFieldLabelSource) - .join("|"); - const pattern = new RegExp( - `^\\s*(?:[-*]\\s*)?${fieldSource}\\s*:?\\s*([^\\n\\r]*)\\n?([\\s\\S]*?)(?=^\\s*(?:[-*]\\s*)?(?:${boundarySource})\\s*:?|^HANDOFF SUMMARY\\s*:|^diff --git\\s|\\ncodex\\n|(?![\\s\\S]))`, - "im", - ); - const match = pattern.exec(text); - if (!match) return ""; - const inline = String(match[1] ?? "").trim(); - const body = String(match[2] ?? "").trim(); - return [inline, body] - .filter((entry) => entry.length > 0) - .join("\n") - .split(/\r?\n/) - .map((line) => line.replace(/^\s*[-*]\s*/, "").trim()) - .filter((line) => line.length > 0) - .join(" "); -} - -function parseLooseMarkdownSection(text: string, sectionName: string): string { - const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const sectionBoundaries = [ - "Outcome", - "Summary", - "Implemented", - "Accomplished", - "What I accomplished", - "What I changed", - "Files Changed", - "FilesChanged", - "Tests Run", - "TestsRun", - "Test Results", - "Tests", - "Validation", - "Warnings", - "Warnings / risks", - "Risks / Notes", - "Next Steps", - "Plan", - "PlanMarkdown", - "Plan Markdown", - "plan.markdown", - "Mission changes from upstream", - "Validation / exact closeout evidence", - "Plan for next phase handoff", - "Risk/notes for downstream", - "Handoff Summary", - ]; - const boundaryPattern = sectionBoundaries - .map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"); - const pattern = new RegExp( - `^(?:#{1,6}\\s+|\\d+\\)\\s*)?${escaped}\\s*$\\n([\\s\\S]*?)(?=^(?:(?:#{1,6}\\s+|\\d+\\)\\s*)?(?:${boundaryPattern})\\s*$)|^diff --git\\s|\\ncodex\\n|(?![\\s\\S]))`, - "im", - ); - const body = pattern.exec(text)?.[1]?.trim() ?? ""; - if (!body.length) return ""; - return body - .split(/\r?\n/) - .map((line) => line.replace(/^\s*[-*]\s*/, "").trim()) - .filter((line) => line.length > 0) - .join(" "); -} - -function extractMarkdownLinkedFileLabels(text: string): string[] { - const files: string[] = []; - const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g; - for (const match of text.matchAll(linkPattern)) { - const label = stripMarkdownLinkLabel(match[0] ?? ""); - if (!label.length) continue; - if (label === ".ade" || label.startsWith(".ade/") || label.startsWith("./.ade/")) continue; - if (!/\.(?:[cm]?[jt]sx?|json|md|css|scss|html|swift|kt|java|py|rb|go|rs|php|sql|ya?ml)$/i.test(label)) continue; - if (!files.includes(label)) files.push(label); - } - return files; -} - -function looksLikeRepoFilePath(value: string): boolean { - const normalized = value.trim().replace(/^\.\/+/, ""); - if (!normalized.length) return false; - if (path.isAbsolute(normalized)) return false; - const segments = normalized.split(/[\\/]+/).filter((segment) => segment.length > 0); - if (segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".."))) return false; - if (normalized === ".ade" || normalized.startsWith(".ade/")) return false; - if (normalized.includes("/.ade/")) return false; - if (normalized.includes("://")) return false; - return /\.(?:[cm]?[jt]sx?|json|md|css|scss|html|swift|kt|java|py|rb|go|rs|php|sql|ya?ml)$/i.test(normalized); -} - -function extractFileMentionsFromMarkdownList(text: string): string[] { - const files: string[] = []; - const pushFile = (candidate: string) => { - const normalized = stripMarkdownLinkLabel(candidate.trim()).replace(/^\.\/+/, ""); - if (!looksLikeRepoFilePath(normalized)) return; - if (!files.includes(normalized)) files.push(normalized); - }; - for (const line of text.split(/\r?\n/)) { - const cleaned = line.replace(/^\s*[-*]\s*/, "").trim(); - if (!cleaned.length) continue; - for (const match of cleaned.matchAll(/`([^`]+)`/g)) { - pushFile(String(match[1] ?? "")); - } - for (const token of cleaned.split(/\s+/)) { - pushFile(token.replace(/[.,;:]$/g, "")); - } - } - return files; -} - -function sliceLikelyCodexResultText(text: string): string { - const lower = text.toLowerCase(); - const explicitReportResultPattern = /(?:^|[\n\r]|[^\w])(?:#{1,6}\s*)?(?:`|\[)?report_result(?:`|\])?\s*:?\s*(?:\r?\n|$)/gi; - let explicitReportResultIndex = -1; - for (const match of text.matchAll(explicitReportResultPattern)) { - explicitReportResultIndex = Math.max(explicitReportResultIndex, match.index ?? -1); - } - if (explicitReportResultIndex >= 0) return text.slice(explicitReportResultIndex); - - const reportPayloadFieldsIndex = /`?report_result`?\s+payload fields\s*:/i.exec(text)?.index ?? -1; - const markerSearchLower = - reportPayloadFieldsIndex >= 0 ? lower.slice(0, reportPayloadFieldsIndex) : lower; - const markers = [ - "## report_result", - "\n### report_result", - "\n## `report_result`", - "\n### `report_result`", - "\n`report_result`\n", - "\nreport_result\n", - "\nreport_result:", - "\n[report_result]", - "\n## outcome", - "\noutcome:", - "\n1) accomplished", - "\n## accomplished", - "\n## what i accomplished", - "\n## what i have accomplished", - "\nwhat i accomplished", - "\n## summary", - "\nsummary:", - "\nimplemented:", - "\naccomplished:", - "\n**outcome**", - ]; - let markerIndex = -1; - for (const marker of markers) { - markerIndex = Math.max(markerIndex, markerSearchLower.lastIndexOf(marker)); - } - if (markerIndex >= 0) { - return text.slice(markerIndex, reportPayloadFieldsIndex >= 0 ? reportPayloadFieldsIndex : undefined); - } - - const codexMarker = lower.lastIndexOf("\ncodex\n"); - return codexMarker >= 0 ? text.slice(codexMarker) : text; -} - -function looksLikeReportTemplatePlaceholder(value: string): boolean { - const normalized = value.trim().toLowerCase(); - return ( - normalized === "<one sentence>" - || normalized === "<one sentence>." - || normalized === "<commands run, or read-only checks only>" - || /^<[^>\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)) ?? ""; -} - -function dedentMarkdownBlock(block: string): string { - const lines = block.replace(/\s+$/g, "").split(/\r?\n/); - const indents = lines - .filter((line) => line.trim().length > 0) - .map((line) => /^[ \t]*/.exec(line)?.[0]?.length ?? 0); - const minIndent = indents.length > 0 ? Math.min(...indents) : 0; - return lines - .map((line) => line.trim().length > 0 ? line.slice(minIndent) : "") - .join("\n") - .trim(); -} - -function extractLoosePlanMarkdown(text: string): string { - const fenced = /(?:^|\n)(?:#{1,6}\s*)?Plan(?:\s*\([^)\r\n]+\))?\s*:?\s*\r?\n```(?:markdown|md)?\s*\r?\n([\s\S]*?)\r?\n```/i.exec(text); - if (fenced?.[1]?.trim()) return fenced[1].trim(); - - const namedPlanArtifact = /(?:^|\n)\s*[-*]\s*`?plan\.markdown`?\s*:?\s*\r?\n\s*```(?:markdown|md)?\s*\r?\n([\s\S]*?)\r?\n\s*```/i.exec(text); - if (namedPlanArtifact?.[1]?.trim()) return namedPlanArtifact[1].trim(); - - const namedPlanListArtifact = /(?:^|\n)\s*[-*]\s*`?plan\.markdown`?\s*:?\s*\r?\n/i.exec(text); - if (namedPlanListArtifact) { - const start = (namedPlanListArtifact.index ?? 0) + namedPlanListArtifact[0].length; - const rest = text.slice(start); - const boundary = /\n\s{0,4}[-*]\s*(?:metadata|outcome|summary|filesChanged|files_changed|testsRun|tests_run|warnings|risks?):\s*/i.exec(rest) - ?? /\n(?:#{1,6}\s*)?(?:Accomplished|Changed Files|Tests Run|Risks\s*\/\s*Notes|HANDOFF SUMMARY|Handoff summary)\s*:?\s*(?:\r?\n|$)/i.exec(rest) - ?? /\n(?:diff --git|codex)\b/i.exec(rest); - const block = boundary ? rest.slice(0, boundary.index) : rest; - const withoutFences = block - .replace(/^\s*```(?:markdown|md)?\s*/i, "") - .replace(/\s*```\s*$/i, ""); - const markdown = dedentMarkdownBlock(withoutFences); - if (markdown.length > 0) return markdown; - } - - const namedPlanHeading = /(?:^|\n)(?:#{1,6}\s*)?plan\.markdown\s*:?\s*\r?\n```(?:markdown|md)?\s*\r?\n([\s\S]*?)\r?\n```/i.exec(text); - if (namedPlanHeading?.[1]?.trim()) return namedPlanHeading[1].trim(); - - const jsonPlanPayload = /(?:^|\n)(?:#{1,6}\s*)?plan(?:\s*\([^)\r\n]+\))?\s*(?:\(first-class payload\))?\s*:?\s*\r?\n```json\s*\r?\n([\s\S]*?)\r?\n```/i.exec(text); - if (jsonPlanPayload?.[1]?.trim()) { - try { - const parsed = asRecord(JSON.parse(jsonPlanPayload[1].trim())); - const markdown = typeof parsed?.markdown === "string" ? parsed.markdown.trim() : ""; - if (markdown.length > 0) return markdown; - } catch { - // Ignore malformed JSON plan payloads and keep trying looser shapes. - } - } - - const reportResultListPlan = /(?:^|\n)\s*[-*]\s*plan:\s*\r?\n\s*[-*]\s*markdown:\s*\|\s*\r?\n/i.exec(text); - if (reportResultListPlan) { - const start = (reportResultListPlan.index ?? 0) + reportResultListPlan[0].length; - const rest = text.slice(start); - const boundary = /\n\s{0,4}[-*]\s*(?:metadata|outcome|summary|filesChanged|files_changed|testsRun|tests_run|warnings|risks?):\s*/i.exec(rest) - ?? /\n(?:HANDOFF SUMMARY|Handoff summary|diff --git|codex)\b/i.exec(rest); - return dedentMarkdownBlock(boundary ? rest.slice(0, boundary.index) : rest); - } - - const heading = /(?:^|\n)(?:#{1,6}\s*)?Plan(?:\s*\([^)\r\n]+\))?\s*:?\s*\r?\n/i.exec(text); - if (!heading) return ""; - const start = (heading.index ?? 0) + heading[0].length; - const rest = text.slice(start); - const boundary = /\n(?:#{1,6}\s+)?(?:report_result payload fields|handoff summary|risk notes|risks?|summary|outcome|what changed|what i changed|accomplished)\s*:?\s*(?:\r?\n|$)/i.exec(rest); - return (boundary ? rest.slice(0, boundary.index) : rest).trim(); -} - -function stripCodexReportPayloadFieldsTail(text: string): string { - return text.replace(/\n\s*`?report_result`?\s+payload fields\s*:[\s\S]*$/i, "").trim(); -} - -function parseCodexFinalMarkdownReport(text: string): Record<string, unknown> | null { - const reportText = stripCodexReportPayloadFieldsTail(sliceLikelyCodexResultText(text)); - const outcomeRaw = ( - parseBoldMarkdownField(reportText, "Outcome") - || parseLooseMarkdownField(reportText, "Outcome") - || parseLooseMarkdownSection(reportText, "Outcome") - ).toLowerCase(); - let summary = firstNonPlaceholderMarkdownValue( - parseBoldMarkdownField(reportText, "Summary"), - parseLooseMarkdownField(reportText, "Summary"), - parseLooseMarkdownSection(reportText, "Summary"), - parseLooseMarkdownField(reportText, "Accomplished"), - parseLooseMarkdownField(reportText, "What I accomplished"), - parseLooseMarkdownSection(reportText, "Accomplished"), - parseLooseMarkdownSection(reportText, "What I accomplished"), - parseLooseMarkdownSection(reportText, "What I Have Accomplished So Far"), - parseLooseMarkdownField(reportText, "Implemented"), - parseLooseMarkdownSection(reportText, "Implemented"), - outcomeRaw - ); - const planMarkdown = extractLoosePlanMarkdown(reportText) || extractLoosePlanMarkdown(text); - if (!summary.length && planMarkdown.length > 0) { - summary = "Planning step completed."; - } - if (!summary.length) return null; - const fileSection = - parseLooseMarkdownSection(reportText, "Files Changed") - || parseLooseMarkdownSection(reportText, "FilesChanged") - || parseLooseMarkdownSection(reportText, "What I changed") - || parseLooseMarkdownField(reportText, "FilesChanged"); - const filesChanged = [ - ...extractMarkdownLinkedFileLabels(reportText), - ...extractFileMentionsFromMarkdownList(fileSection), - ].filter((file, index, all) => all.indexOf(file) === index); - const testsText = [ - parseLooseMarkdownSection(reportText, "Tests Run"), - parseLooseMarkdownSection(reportText, "TestsRun"), - parseLooseMarkdownSection(reportText, "Test Results"), - parseLooseMarkdownSection(reportText, "Tests"), - parseLooseMarkdownSection(reportText, "Validation"), - parseLooseMarkdownField(reportText, "TestsRun"), - parseLooseMarkdownField(reportText, "Tests"), - parseLooseMarkdownField(reportText, "Validation"), - ].filter((entry) => entry.length > 0).join("\n"); - const testsSearchText = testsText.length > 0 ? testsText : reportText; - const command = - /`([^`]*npm\s+test[^`]*)`/i.exec(testsSearchText)?.[1]?.trim() - ?? /\bnpm\s+test\b/i.exec(testsSearchText)?.[0]?.trim(); - const count = (labels: string[]): number | undefined => { - const labelPattern = labels - .map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("|"); - const labelFirst = new RegExp(`(?:^|[^\\w])(?:${labelPattern})\\s*:?\\s*\`?(\\d+)\`?\\b`, "i"); - const valueFirst = new RegExp(`\\b\`?(\\d+)\`?\\s+(?:${labelPattern})\\b`, "i"); - const match = labelFirst.exec(testsSearchText) ?? valueFirst.exec(testsSearchText); - if (!match?.[1]) return undefined; - const value = Number(match[1]); - return Number.isFinite(value) ? value : undefined; - }; - const slashPassed = - /(\d+)\s*\/\s*\d+\s+passing/i.exec(testsSearchText)?.[1] - ?? /PASS\s*\(\s*(\d+)\s*\/\s*\d+\s+tests?\s*\)/i.exec(testsSearchText)?.[1] - ?? /Result:\s*pass\s*\(\s*(\d+)\s*\/\s*\d+\s+tests?\s*\)/i.exec(testsSearchText)?.[1]; - const passed = slashPassed && Number.isFinite(Number(slashPassed)) ? Number(slashPassed) : count(["passed", "pass"]); - const failed = count(["failed", "fail"]); - const skipped = count(["skipped", "skip"]); - const outcome = (() => { - const zeroFailureSignal = /\b(?:0\s+(?:failed|failures?)|(?:failed|failures?)\s*:?\s*0|no\s+(?:functional\s+)?(?:test\s+)?(?:failures?|blockers?)|zero[-\s]+failures?)\b/i; - const passSignal = /\b(?:pass|passed|passes|passing|success|successful|successfully)\b/i; - if (failed != null && failed > 0) return "failed"; - if (outcomeRaw.includes("partial")) return "partial"; - if (outcomeRaw.includes("fail") && !zeroFailureSignal.test(outcomeRaw) && !passSignal.test(outcomeRaw)) { - return "failed"; - } - return "succeeded"; - })(); - const testsRun = - command || passed != null || failed != null || skipped != null - ? { - ...(command ? { command } : {}), - ...(passed != null ? { passed } : {}), - ...(failed != null ? { failed } : {}), - ...(skipped != null ? { skipped } : {}), - raw: [ - command ? `Ran \`${command}\`` : null, - passed != null ? `${passed} passed` : null, - failed != null ? `${failed} failed` : null, - skipped != null ? `${skipped} skipped` : null, - ].filter((entry): entry is string => Boolean(entry)).join(", "), - } - : null; - return { - outcome, - summary, - filesChanged, - ...(testsRun ? { testsRun } : {}), - ...(planMarkdown.length > 0 ? { plan: { markdown: planMarkdown } } : {}), - }; -} - -function sliceLastCodexTurnText(text: string): string { - const matches = [...text.matchAll(/(?:^|\n)codex\r?\n/gim)]; - const match = matches.at(-1); - if (!match?.index && match?.index !== 0) return text; - return text.slice(match.index + match[0].length); -} - -function hasLikelyCodexResultMarker(text: string): boolean { - return ( - /(?:^|\n)\s*`?report_result`?\s*:?\s*(?:\r?\n|$)/i.test(text) - || /(?:^|\n)\s*implemented\s*:\s*/i.test(text) - || /(?:^|\n)\s*(?:#{1,6}\s*)?what i accomplished\s*(?:\r?\n|$)/i.test(text) - || /(?:^|\n)\s*(?:#{1,6}\s*)?what i changed\s*(?:\r?\n|$)/i.test(text) - ); -} - -function extractReportResultPayloadFromText(rawText: string): Record<string, unknown> | null { - const raw = stripAnsiCodes(rawText); - if (!raw.trim().length) return null; - const markerIndex = raw.lastIndexOf("[report_result]"); - if (markerIndex >= 0) { - const jsonStart = raw.indexOf("{", markerIndex); - if (jsonStart >= 0) { - const markedPayload = parseJsonObjectAt(raw, jsonStart); - if (looksLikeWorkerResultReportPayload(markedPayload)) return markedPayload; - } - } - const lastCodexTurn = sliceLastCodexTurnText(raw); - const candidateTexts = lastCodexTurn === raw - ? [{ text: raw, allowMissingTrailingBraces: true }] - : [ - { text: lastCodexTurn, allowMissingTrailingBraces: true }, - { text: raw, allowMissingTrailingBraces: false }, - ]; - for (const candidate of candidateTexts) { - const candidateText = candidate.text; - const yamlishPayload = parseCodexYamlishReportResult(candidateText); - if (yamlishPayload && looksLikeWorkerResultReportPayload(yamlishPayload)) return yamlishPayload; - const markdownPayload = parseReportResultMarkdown(candidateText); - if (markdownPayload && looksLikeWorkerResultReportPayload(markdownPayload)) return markdownPayload; - const jsonPayload = findLastWorkerResultReportJson(candidateText, { - allowMissingTrailingBraces: candidate.allowMissingTrailingBraces, - }); - if (jsonPayload && looksLikeWorkerResultReportPayload(jsonPayload)) return jsonPayload; - const codexMarkdownPayload = parseCodexFinalMarkdownReport(candidateText); - if (codexMarkdownPayload && looksLikeWorkerResultReportPayload(codexMarkdownPayload)) return codexMarkdownPayload; - } - if (lastCodexTurn !== raw && hasLikelyCodexResultMarker(lastCodexTurn)) return null; - const yamlishPayload = parseCodexYamlishReportResult(raw); - if (yamlishPayload && looksLikeWorkerResultReportPayload(yamlishPayload)) return yamlishPayload; - const markdownPayload = parseReportResultMarkdown(raw); - if (markdownPayload && looksLikeWorkerResultReportPayload(markdownPayload)) return markdownPayload; - const jsonPayload = findLastWorkerResultReportJson(raw); - if (jsonPayload && looksLikeWorkerResultReportPayload(jsonPayload)) return jsonPayload; - return null; -} - -const REPORT_RESULT_TRANSCRIPT_TAIL_BYTES = 128 * 1024; -const REPORT_RESULT_MAX_CANDIDATE_CHARS = 48_000; -const REPORT_RESULT_MAX_CANDIDATES = 24; - -function appendBoundedReportResultText(current: string, next: string): string { - if (!current.length) { - return next.length > REPORT_RESULT_MAX_CANDIDATE_CHARS - ? next.slice(-REPORT_RESULT_MAX_CANDIDATE_CHARS) - : next; - } - const combined = `${current}${next}`; - return combined.length > REPORT_RESULT_MAX_CANDIDATE_CHARS - ? combined.slice(-REPORT_RESULT_MAX_CANDIDATE_CHARS) - : combined; -} - -function agentChatAssistantTextCandidates(rawTranscript: string): string[] { - const events = parseAgentChatTranscript(rawTranscript); - const byMessage = new Map<string, string>(); - let allText = ""; - let anonymousIndex = 0; - for (const entry of events) { - if (entry.event.type !== "text") continue; - const text = typeof entry.event.text === "string" ? entry.event.text : ""; - if (!text.length) continue; - allText = appendBoundedReportResultText(allText, text); - const eventRecord = entry.event as Record<string, unknown>; - const key = - typeof eventRecord.messageId === "string" && eventRecord.messageId.trim().length > 0 - ? eventRecord.messageId.trim() - : typeof eventRecord.turnId === "string" && eventRecord.turnId.trim().length > 0 - ? eventRecord.turnId.trim() - : `anonymous:${anonymousIndex++}`; - byMessage.set(key, appendBoundedReportResultText(byMessage.get(key) ?? "", text)); - } - const candidates = Array.from(byMessage.values()).reverse().slice(0, REPORT_RESULT_MAX_CANDIDATES); - if (allText.trim().length > 0 && !candidates.includes(allText)) { - candidates.push(allText); - } - return candidates.filter((candidate) => candidate.trim().length > 0); -} - -function extractReportResultPayloadFromTranscript(filePath: string | null | undefined): Record<string, unknown> | 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); - if (normalizedPath.endsWith(".chat.jsonl")) { - for (const candidate of agentChatAssistantTextCandidates(rawTail)) { - const payload = extractReportResultPayloadFromText(candidate); - if (payload) return payload; - } - } - return extractReportResultPayloadFromText(rawTail); - } catch { - return null; - } -} - -function extractReportResultPayloadFromStepOutput(filePath: string | null | undefined): Record<string, unknown> | 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 extractReportResultPayloadFromWorkerStepOutput(args: { - worktreePath: string; - stepKey: string; - attemptId: string | number; -}): Record<string, unknown> | null { - return ( - extractReportResultPayloadFromStepOutput(getWorkerStepOutputPath(args.worktreePath, args.stepKey, args.attemptId)) - ?? extractReportResultPayloadFromStepOutput(getLegacyWorkerStepOutputPath(args.worktreePath, args.stepKey)) - ); -} - -function normalizeRecoveredWorkerResultReport(args: { - payload: Record<string, unknown>; - step: OrchestratorStep; - attempt: OrchestratorAttempt; - missionId: string; - runId: string; -}): WorkerResultReport | null { - const rawOutcome = typeof args.payload.outcome === "string" ? args.payload.outcome.trim().toLowerCase() : ""; - const outcome: WorkerResultReport["outcome"] = - rawOutcome === "failed" || rawOutcome === "partial" || rawOutcome === "succeeded" - ? rawOutcome - : "succeeded"; - const rawPlan = asRecord(args.payload.plan); - 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) - && !looksLikePlanTemplatePlaceholder(rawPlan.summary) - ? rawPlan.summary.trim() - : ""; - const normalizedPlan = rawPlan && planMarkdown.length > 0 - ? { - markdown: planMarkdown, - ...(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; - 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() - : planSummary.length > 0 - ? planSummary - : normalizedPlan - ? "Planning step completed." - : ""; - 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) - ? args.payload.filesChanged - : Array.isArray(args.payload.files_changed) - ? args.payload.files_changed - : []; - const testsRunRaw = asRecord(args.payload.testsRun) ?? asRecord(args.payload.tests_run); - return { - workerId: args.step.stepKey, - stepId: args.step.id, - stepKey: args.step.stepKey, - runId: args.runId, - missionId: args.missionId, - outcome, - summary: summary || "Worker completed.", - ...(normalizedPlan ? { plan: normalizedPlan } : {}), - artifacts: artifactsRaw - .map((entry) => asRecord(entry)) - .filter((entry): entry is Record<string, unknown> => Boolean(entry)) - .map((artifact) => ({ - type: typeof artifact.type === "string" && artifact.type.trim().length > 0 ? artifact.type.trim() : "artifact", - title: typeof artifact.title === "string" && artifact.title.trim().length > 0 ? artifact.title.trim() : "Untitled artifact", - uri: typeof artifact.uri === "string" && artifact.uri.trim().length > 0 ? artifact.uri.trim() : null, - metadata: asRecord(artifact.metadata) ?? undefined, - })), - filesChanged: filesChangedRaw - .map((entry) => String(entry ?? "").trim().replace(/^\.\/+/, "")) - .filter((entry) => looksLikeRepoFilePath(entry)), - testsRun: testsRunRaw - ? { - ...(typeof testsRunRaw.command === "string" && testsRunRaw.command.trim().length > 0 ? { command: testsRunRaw.command.trim() } : {}), - ...(Number.isFinite(Number(testsRunRaw.passed)) ? { passed: Number(testsRunRaw.passed) } : {}), - ...(Number.isFinite(Number(testsRunRaw.failed)) ? { failed: Number(testsRunRaw.failed) } : {}), - ...(Number.isFinite(Number(testsRunRaw.skipped)) ? { skipped: Number(testsRunRaw.skipped) } : {}), - ...(testsRunRaw.raw != null ? { raw: String(testsRunRaw.raw) } : {}), - } - : null, - laneId: args.step.laneId, - reportedAt: nowIso(), - }; -} - -function deriveTranscriptSummaryFromPath(filePath: string | null | undefined): string | null { - const normalizedPath = typeof filePath === "string" ? filePath.trim() : ""; - if (!normalizedPath || !fs.existsSync(normalizedPath)) return null; - try { - const rawTail = readUtf8Tail(normalizedPath); - const events = parseAgentChatTranscript(rawTail); - if (events.length > 0) { - return deriveAgentChatTranscriptSummary(events); - } - const sanitizedTail = stripAnsiCodes(rawTail) - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .filter((line) => !isWorkerBootstrapNoiseLine(line)) - .join("\n"); - const summary = deriveSessionSummaryFromText(sanitizedTail).trim(); - if (!summary.length) return null; - const lowerSummary = summary.toLowerCase(); - if ( - lowerSummary.startsWith("ade_mission_id=") - || lowerSummary.startsWith("-p \"$(cat ") - || lowerSummary.includes("worker-prompts/worker-") - || lowerSummary.includes("exec claude --model") - || lowerSummary.includes("exec codex ") - ) { - return null; - } - return summary; - } catch { - return null; - } -} - -function extractNaturalQuestionFromTranscriptPath(filePath: string | null | undefined): string | null { - const normalizedPath = typeof filePath === "string" ? filePath.trim() : ""; - if (!normalizedPath || !fs.existsSync(normalizedPath)) return null; - try { - const rawTail = stripAnsiCodes(readUtf8Tail(normalizedPath, 96 * 1024)); - const lines = rawTail - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .filter((line) => !isWorkerBootstrapNoiseLine(line)); - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]!; - if (!line.includes("?")) continue; - const compact = line.replace(/\s+/g, " ").trim(); - if (compact.length < 8 || compact.length > 500) continue; - const lower = compact.toLowerCase(); - if ( - lower.startsWith("if ") - || lower.startsWith("- if ") - || lower.includes("ask_user") - || lower.includes("request_user_input") - || lower.includes("phase question policy") - || lower.includes("planning phase instructions") - || lower.includes("should ask one clarification") - ) { - continue; - } - return compact; - } - return null; - } catch { - return null; - } -} - -function analyzeTranscriptFromPath(filePath: string | null | undefined): { - summary: string | null; - hasMaterialOutput: boolean; - hasLifecycleActivity: boolean; - isStructuredChat: boolean; - appearsInterruptedDuringCommand: boolean; -} { - const normalizedPath = typeof filePath === "string" ? filePath.trim() : ""; - if (!normalizedPath || !fs.existsSync(normalizedPath)) { - return { summary: null, hasMaterialOutput: false, hasLifecycleActivity: false, isStructuredChat: false, appearsInterruptedDuringCommand: false }; - } - try { - const rawTail = readUtf8Tail(normalizedPath); - const events = parseAgentChatTranscript(rawTail); - if (events.length > 0) { - return { - summary: deriveAgentChatTranscriptSummary(events), - hasMaterialOutput: hasMaterialWorkerChatEvent(events), - hasLifecycleActivity: hasWorkerChatLifecycleEvent(events), - isStructuredChat: true, - appearsInterruptedDuringCommand: false, - }; - } - const summary = deriveTranscriptSummaryFromPath(normalizedPath); - return { - summary, - hasMaterialOutput: summary != null, - hasLifecycleActivity: summary != null, - isStructuredChat: false, - appearsInterruptedDuringCommand: transcriptTailEndsWithOpenCommand(rawTail), - }; - } catch { - return { - summary: null, - hasMaterialOutput: false, - hasLifecycleActivity: false, - isStructuredChat: normalizedPath.endsWith(".chat.jsonl"), - appearsInterruptedDuringCommand: false, - }; - } -} - -function transcriptTailEndsWithOpenCommand(rawText: string): boolean { - const lines = stripAnsiCodes(rawText) - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !isWorkerBootstrapNoiseLine(line)); - const lastLine = lines.at(-1) ?? ""; - if (!lastLine.length) return false; - return /(?:^|\s)(?:\/bin\/(?:zsh|bash|sh)|(?:zsh|bash|sh))\s+-lc\s+['"]/.test(lastLine) - && /\s+in\s+\//.test(lastLine); -} - -function classifySilentWorkerExit(args: { - stepMetadata: Record<string, unknown> | null | undefined; - transcriptSummary: string | null; - hasMaterialOutput: boolean; - hasLifecycleActivity: boolean; - appearsInterruptedDuringCommand: boolean; -}): { errorClass: OrchestratorErrorClass; errorMessage: string } | null { - if (args.appearsInterruptedDuringCommand) { - return { - errorClass: "interrupted", - errorMessage: isPlanningLikeStepMetadata(args.stepMetadata) - ? "Planning worker session was interrupted while a shell command was still running before report_result.plan.markdown." - : "Worker session was interrupted while a shell command was still running before report_result.", - }; - } - if (args.transcriptSummary) return null; - if (isPlanningLikeStepMetadata(args.stepMetadata)) { - if (args.hasLifecycleActivity && !args.hasMaterialOutput) { - return { - errorClass: "interrupted", - errorMessage: "Planning worker session started but was interrupted before producing any assistant or tool activity.", - }; - } - if (!args.hasMaterialOutput) { - return { - errorClass: "startup_failure", - errorMessage: "Planning worker exited before producing any assistant or tool activity.", - }; - } - return { - errorClass: "executor_failure", - errorMessage: "Planning worker exited after partial activity without reporting a usable plan summary.", - }; - } - if (args.hasLifecycleActivity && !args.hasMaterialOutput) { - return { - errorClass: "interrupted", - errorMessage: "Worker session started but was interrupted before producing any assistant or tool activity.", - }; - } - if (!args.hasMaterialOutput) { - return { - errorClass: "startup_failure", - errorMessage: "Worker session ended before producing any assistant or tool activity.", - }; - } - // VAL-ERR-001: Workers with material output but no transcript summary were - // interrupted — classify as "interrupted" rather than returning null (which - // would allow a "succeeded" status to stand for a worker that never reported - // a proper result). - return { - errorClass: "interrupted", - errorMessage: "Worker session ended after partial activity without reporting a final result.", - }; -} - -function resolveRunPhaseCardsFromMetadata(runMetadata: Record<string, unknown> | null | undefined): PhaseCard[] | null { - if (!runMetadata) return null; - const phaseConfig = typeof runMetadata.phaseConfiguration === "object" && runMetadata.phaseConfiguration - ? (runMetadata.phaseConfiguration as Record<string, unknown>) - : null; - const phaseCards = - phaseConfig && Array.isArray(phaseConfig.phases) - ? phaseConfig.phases as PhaseCard[] - : phaseConfig && Array.isArray(phaseConfig.selectedPhases) - ? phaseConfig.selectedPhases as PhaseCard[] - : Array.isArray(runMetadata.phaseOverride) - ? runMetadata.phaseOverride as PhaseCard[] - : null; - return phaseCards && phaseCards.length > 0 ? phaseCards : null; -} - -function buildInitialPhaseRuntime(phaseCards: PhaseCard[] | null | undefined): Record<string, unknown> | null { - if (!Array.isArray(phaseCards) || phaseCards.length === 0) return null; - const initialPhase = [...phaseCards].sort((left, right) => left.position - right.position)[0]!; - const transitionedAt = nowIso(); - return { - currentPhaseKey: initialPhase.phaseKey, - currentPhaseName: initialPhase.name, - currentPhaseModel: initialPhase.model, - currentPhaseInstructions: initialPhase.instructions, - currentPhaseValidation: initialPhase.validationGate, - currentPhaseBudget: initialPhase.budget ?? {}, - transitionedAt, - transitions: [ - { - fromPhaseKey: null, - fromPhaseName: null, - toPhaseKey: initialPhase.phaseKey, - toPhaseName: initialPhase.name, - at: transitionedAt, - reason: "run_initialized" - } - ], - phaseBudgets: { - [initialPhase.phaseKey]: { - enteredAt: transitionedAt, - usedTokens: 0, - usedCostUsd: 0 - } - } - }; -} - -function resolveRunMissionLevelSettingsFromMetadata( - runMetadata: Record<string, unknown> | null | undefined, -): MissionLevelSettings | null { - if (!runMetadata) return null; - if (typeof runMetadata.missionLevelSettings === "object" && runMetadata.missionLevelSettings) { - return runMetadata.missionLevelSettings as MissionLevelSettings; - } - const executionPolicy = isExecutionPolicyRecord(runMetadata.executionPolicy) - ? (runMetadata.executionPolicy as MissionExecutionPolicy) - : null; - if (!executionPolicy && !Array.isArray(runMetadata.phaseOverride)) return null; - return { - prStrategy: executionPolicy?.prStrategy ?? { kind: "manual" }, - ...(executionPolicy?.integrationPr ? { integrationPr: executionPolicy.integrationPr } : {}), - ...(executionPolicy?.teamRuntime ? { teamRuntime: executionPolicy.teamRuntime } : {}), - }; -} - -function isPlanningLikeStepMetadata(stepMetadata: Record<string, unknown> | null | undefined): boolean { - if (!stepMetadata) return false; - const stepType = typeof stepMetadata.stepType === "string" ? stepMetadata.stepType.trim().toLowerCase() : ""; - const phaseKey = typeof stepMetadata.phaseKey === "string" ? stepMetadata.phaseKey.trim().toLowerCase() : ""; - return stepMetadata.readOnlyExecution === true || stepType === "planning" || phaseKey === "planning"; -} - -function isValidationLikeStepMetadata(stepMetadata: Record<string, unknown> | null | undefined): boolean { - if (!stepMetadata) return false; - const stepType = typeof stepMetadata.stepType === "string" ? stepMetadata.stepType.trim().toLowerCase() : ""; - const taskType = typeof stepMetadata.taskType === "string" ? stepMetadata.taskType.trim().toLowerCase() : ""; - const phaseKey = typeof stepMetadata.phaseKey === "string" ? stepMetadata.phaseKey.trim().toLowerCase() : ""; - const phaseName = typeof stepMetadata.phaseName === "string" ? stepMetadata.phaseName.trim().toLowerCase() : ""; - return ( - stepMetadata.autoSpawnedValidation === true - || stepType === "validation" - || taskType === "validation" - || phaseKey === "validation" - || phaseName === "validation" - ); -} - -function resolveCurrentRunPhaseCard( - runMetadata: Record<string, unknown> | null | undefined, - phaseCards: PhaseCard[] | null | undefined, -): PhaseCard | null { - if (!runMetadata || !Array.isArray(phaseCards) || phaseCards.length === 0) return null; - const phaseRuntime = asRecord(runMetadata.phaseRuntime); - const currentPhaseKey = typeof phaseRuntime?.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - const currentPhaseName = typeof phaseRuntime?.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - if (currentPhaseKey.length > 0) { - const byKey = phaseCards.find((phase) => phase.phaseKey === currentPhaseKey); - if (byKey) return byKey; - } - if (currentPhaseName.length > 0) { - const byName = phaseCards.find((phase) => phase.name === currentPhaseName); - if (byName) return byName; - } - return null; -} - -function resolvePhaseCardForStep( - step: OrchestratorStep, - phaseCards: PhaseCard[] | null | undefined, -): PhaseCard | null { - if (!Array.isArray(phaseCards) || phaseCards.length === 0) return null; - const stepMetadata = asRecord(step.metadata); - const stepPhaseKey = typeof stepMetadata?.phaseKey === "string" ? stepMetadata.phaseKey.trim() : ""; - const stepPhaseName = typeof stepMetadata?.phaseName === "string" ? stepMetadata.phaseName.trim() : ""; - if (stepPhaseKey.length > 0) { - const byKey = phaseCards.find((phase) => phase.phaseKey === stepPhaseKey); - if (byKey) return byKey; - } - if (stepPhaseName.length > 0) { - const byName = phaseCards.find((phase) => phase.name === stepPhaseName); - if (byName) return byName; - } - return null; -} - -function stepRequiresValidation( - step: OrchestratorStep, - phaseCards: PhaseCard[] | null | undefined, -): boolean { - const stepMetadata = asRecord(step.metadata); - if (isValidationLikeStepMetadata(stepMetadata)) return false; - const validationContract = parseValidationContract(stepMetadata?.validationContract ?? null); - if (validationContract?.required) return true; - const phaseCard = resolvePhaseCardForStep(step, phaseCards); - if (!phaseCard) return false; - return phaseCard.validationGate.required === true && normalizeValidationTier(phaseCard.validationGate.tier) !== "none"; -} - -function stepMatchesPhase(step: OrchestratorStep, phase: PhaseCard): boolean { - const stepMetadata = asRecord(step.metadata); - const stepPhaseKey = typeof stepMetadata?.phaseKey === "string" ? stepMetadata.phaseKey.trim() : ""; - const stepPhaseName = typeof stepMetadata?.phaseName === "string" ? stepMetadata.phaseName.trim() : ""; - return stepPhaseKey === phase.phaseKey || stepPhaseName === phase.name; -} - -function isValidationPhase(phase: PhaseCard): boolean { - return phase.phaseKey.trim().toLowerCase() === "validation" || phase.name.trim().toLowerCase() === "validation"; -} - -function getExecutionStepsForPhase(phase: PhaseCard, steps: OrchestratorStep[]): OrchestratorStep[] { - return filterExecutionSteps(steps.filter((step) => stepMatchesPhase(step, phase))); -} - -function phaseHasTerminalSuccessfulExecution(phase: PhaseCard, steps: OrchestratorStep[]): boolean { - const phaseSteps = getExecutionStepsForPhase(phase, steps); - if (phaseSteps.length === 0) return false; - const allTerminalWithoutFailure = phaseSteps.every( - (step) => step.status === "succeeded" || step.status === "skipped" || step.status === "superseded", - ); - const hasConcreteSuccess = phaseSteps.some((step) => step.status === "succeeded"); - return allTerminalWithoutFailure && hasConcreteSuccess; -} - -function phaseHasSuccessfulCompletionRuntime(phase: PhaseCard, steps: OrchestratorStep[]): boolean { - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - const phaseSteps = getExecutionStepsForPhase(phase, steps); - if (phaseSteps.length === 0) return false; - if (phaseKey === "planning" || phaseName === "planning") { - return phaseSteps.some((step) => isPlanningLikeStepMetadata(asRecord(step.metadata)) && step.status === "succeeded"); - } - const allTerminalWithoutFailure = phaseHasTerminalSuccessfulExecution(phase, steps); - const allRequiredValidationPassed = phaseSteps.every((step) => { - if (step.status !== "succeeded") return true; - if (!stepRequiresValidation(step, [phase])) return true; - return hasPassingValidation(asRecord(step.metadata)); - }); - return allTerminalWithoutFailure && allRequiredValidationPassed; -} - -function phaseHasNonTerminalExecutionWork(phase: PhaseCard, steps: OrchestratorStep[]): boolean { - return getExecutionStepsForPhase(phase, steps).some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); -} - -function phaseHasPendingRequiredValidation( - phase: PhaseCard, - steps: OrchestratorStep[], - phaseCards: PhaseCard[] | null | undefined, -): boolean { - return getExecutionStepsForPhase(phase, steps).some((step) => { - if (step.status !== "succeeded") return false; - if (!stepRequiresValidation(step, phaseCards)) return false; - return !hasPassingValidation(asRecord(step.metadata)); - }); -} - -function phaseHasAssignedExecutionWork(phase: PhaseCard, steps: OrchestratorStep[]): boolean { - return getExecutionStepsForPhase(phase, steps).length > 0; -} - -function canEnterConfiguredPhase( - targetPhase: PhaseCard, - phases: PhaseCard[], - steps: OrchestratorStep[], -): boolean { - const targetIndex = phases.findIndex((phase) => phase.phaseKey === targetPhase.phaseKey); - if (targetIndex < 0) return false; - const targetIsValidationPhase = isValidationPhase(targetPhase); - const canValidateEarlierDedicatedPhase = (phase: PhaseCard): boolean => - targetIsValidationPhase - && normalizeValidationTier(phase.validationGate.tier) === "dedicated" - && phaseHasTerminalSuccessfulExecution(phase, steps); - const explicitMustFollow = (targetPhase.orderingConstraints.mustFollow ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - for (let index = 0; index < targetIndex; index += 1) { - const earlier = phases[index]!; - const mustComplete = earlier.orderingConstraints.mustBeFirst || (explicitMustFollow.length === 0 && earlier.validationGate.required); - if (mustComplete && !phaseHasSuccessfulCompletionRuntime(earlier, steps)) { - if (canValidateEarlierDedicatedPhase(earlier)) continue; - return false; - } - } - for (const predecessorKey of explicitMustFollow) { - if (!predecessorKey.length) continue; - const predecessor = phases.find((phase) => phase.phaseKey === predecessorKey || phase.name === predecessorKey); - if (predecessor && !phaseHasSuccessfulCompletionRuntime(predecessor, steps)) { - if (canValidateEarlierDedicatedPhase(predecessor)) continue; - return false; - } - } - return true; -} - -export function createOrchestratorService({ - db, - projectId, - projectRoot, - conflictService, - ptyService, - agentChatService, - prService, - projectConfigService, - aiIntegrationService, - onEvent -}: { - db: AdeDb; - projectId: string; - projectRoot: string; - conflictService?: ReturnType<typeof createConflictService>; - ptyService?: ReturnType<typeof createPtyService>; - agentChatService?: ReturnType<typeof createAgentChatService> | null; - prService?: ReturnType<typeof createPrService>; - projectConfigService?: ReturnType<typeof createProjectConfigService> | null; - aiIntegrationService?: ReturnType<typeof createAiIntegrationService> | null; - onEvent?: (event: OrchestratorEvent) => void; -}) { - const adapters = new Map<OrchestratorExecutorKind, OrchestratorExecutorAdapter>(); - const sharedAdapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService, - }); - for (const kind of ["claude", "codex", "cursor", "droid", "opencode"] as const) { - adapters.set(kind, sharedAdapter); - } - const autopilotRunLocks = new Set<string>(); - const autopilotCapSignatures = new Map<string, string>(); - const recoveryLoopStates = new Map<string, RecoveryLoopState>(); - const toOptionalNonEmptyString = (value: unknown): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }; - const collectUniqueStrings = (...values: unknown[]): string[] => { - const out: string[] = []; - for (const value of values) { - const candidate = toOptionalNonEmptyString(value); - if (candidate && !out.includes(candidate)) out.push(candidate); - } - return out; - }; - const resolvePlanningPolicyPrompt = (args: { - missionId?: string | null; - runMetadata?: Record<string, unknown> | null; - stepMetadata?: Record<string, unknown> | null; - }): string | null => { - const runMetadata = asRecord(args.runMetadata); - const stepMetadata = asRecord(args.stepMetadata); - const candidates: string[] = []; - const push = (...values: unknown[]) => { - for (const candidate of collectUniqueStrings(...values)) { - if (!candidates.includes(candidate)) candidates.push(candidate); - } - }; - push(runMetadata?.missionPrompt, runMetadata?.missionGoal); - if (args.missionId) { - try { - const row = db.get<{ prompt?: unknown }>( - `select prompt from missions where id = ? and project_id = ? limit 1`, - [args.missionId, projectId], - ); - push(row?.prompt); - } catch { - // The policy can still use runtime and step text. - } - } - push(stepMetadata?.instructions, stepMetadata?.phaseInstructions); - return candidates.length > 0 ? candidates.join("\n\n") : null; - }; - const normalizePlanningQuestionStepMetadata = (args: { - missionId?: string | null; - runMetadata?: Record<string, unknown> | null; - metadata: Record<string, unknown>; - }): Record<string, unknown> => { - const metadata = args.metadata; - const phaseKey = toOptionalNonEmptyString(metadata.phaseKey)?.toLowerCase() ?? ""; - const phaseName = toOptionalNonEmptyString(metadata.phaseName)?.toLowerCase() ?? ""; - if (phaseKey !== "planning" && phaseName !== "planning") return metadata; - const phaseAskQuestions = asRecord(metadata.phaseAskQuestions); - if (phaseAskQuestions?.enabled !== true || phaseAskQuestions.requiredBeforeExit === true) return metadata; - - const phases = resolveRunPhaseCardsFromMetadata(args.runMetadata) ?? []; - const matchingPhase = phases.find((phase) => - phase.phaseKey.trim().toLowerCase() === phaseKey || phase.name.trim().toLowerCase() === phaseName - ) ?? null; - const phaseForPolicy = { - ...(matchingPhase ?? {}), - phaseKey: toOptionalNonEmptyString(metadata.phaseKey) ?? matchingPhase?.phaseKey ?? "planning", - name: toOptionalNonEmptyString(metadata.phaseName) ?? matchingPhase?.name ?? "Planning", - instructions: collectUniqueStrings( - matchingPhase?.instructions, - metadata.phaseInstructions, - metadata.instructions, - ).join("\n\n"), - askQuestions: { - ...(matchingPhase?.askQuestions ?? {}), - ...phaseAskQuestions, - enabled: true, - requiredBeforeExit: matchingPhase?.askQuestions?.requiredBeforeExit === true, - }, - } as PhaseCard; - const requiredBeforeExit = phaseRequiresPlanningQuestionBeforeExit({ - phase: phaseForPolicy, - missionPrompt: resolvePlanningPolicyPrompt({ - missionId: args.missionId, - runMetadata: args.runMetadata, - stepMetadata: metadata, - }), - }); - if (!requiredBeforeExit) return metadata; - return { - ...metadata, - phaseAskQuestions: { - ...phaseAskQuestions, - requiredBeforeExit: true, - }, - }; - }; - const resolveRunMissionLaneId = (metadata: Record<string, unknown> | null | undefined): string | null => { - const metadataRecord = metadata ? asRecord(metadata) : null; - if (!metadataRecord) return null; - const directLaneId = toOptionalNonEmptyString(metadataRecord.missionLaneId); - if (directLaneId) return directLaneId; - - const coordinatorMeta = asRecord(metadataRecord.coordinator); - const coordinatorLaneId = toOptionalNonEmptyString(coordinatorMeta?.missionLaneId); - if (coordinatorLaneId) return coordinatorLaneId; - - const teamRuntimeMeta = asRecord(metadataRecord.teamRuntime); - return toOptionalNonEmptyString(teamRuntimeMeta?.missionLaneId); - }; - const getRuntimeConfig = (): ResolvedOrchestratorRuntimeConfig => { - const snapshot = projectConfigService?.get(); - const ai = asRecord(snapshot?.effective?.ai); - const orchestrator = asRecord(ai?.orchestrator); - if (!orchestrator) return DEFAULT_ORCHESTRATOR_RUNTIME_CONFIG; - const out: ResolvedOrchestratorRuntimeConfig = { ...DEFAULT_ORCHESTRATOR_RUNTIME_CONFIG }; - const teammatePlanMode = String(orchestrator.teammatePlanMode ?? "").trim(); - if (teammatePlanMode === "off" || teammatePlanMode === "auto" || teammatePlanMode === "required") { - out.teammatePlanMode = teammatePlanMode; - } - out.maxParallelWorkers = asIntInRange( - orchestrator.maxParallelWorkers, - out.maxParallelWorkers, - 1, - 32 - ); - const mergePolicy = String(orchestrator.defaultMergePolicy ?? "").trim(); - if (mergePolicy === "sequential" || mergePolicy === "batch-at-end" || mergePolicy === "per-step") { - out.defaultMergePolicy = mergePolicy; - } - const conflictHandoff = String(orchestrator.defaultConflictHandoff ?? "").trim(); - if (conflictHandoff === "auto-resolve" || conflictHandoff === "ask-user" || conflictHandoff === "orchestrator-decides") { - out.defaultConflictHandoff = conflictHandoff; - } - out.workerHeartbeatIntervalMs = asIntInRange( - orchestrator.workerHeartbeatIntervalMs, - out.workerHeartbeatIntervalMs, - 1_000, - 600_000 - ); - out.workerHeartbeatTimeoutMs = asIntInRange( - orchestrator.workerHeartbeatTimeoutMs, - out.workerHeartbeatTimeoutMs, - 1_000, - 900_000 - ); - out.workerIdleTimeoutMs = asIntInRange( - orchestrator.workerIdleTimeoutMs, - out.workerIdleTimeoutMs, - 1_000, - 3_600_000 - ); - out.stepTimeoutDefaultMs = asIntInRange( - orchestrator.stepTimeoutDefaultMs, - out.stepTimeoutDefaultMs, - 1_000, - 3_600_000 - ); - out.maxRetriesPerStep = asIntInRange( - orchestrator.maxRetriesPerStep, - out.maxRetriesPerStep, - 0, - 8 - ); - out.contextPressureThreshold = asNumberInRange( - orchestrator.contextPressureThreshold, - out.contextPressureThreshold, - 0.1, - 0.99 - ); - out.progressiveLoading = asBool(orchestrator.progressiveLoading, out.progressiveLoading); - out.maxTotalTokenBudget = asPositiveNumberOrNull(orchestrator.maxTotalTokenBudget); - out.maxPerStepTokenBudget = asPositiveNumberOrNull(orchestrator.maxPerStepTokenBudget); - const reservationGuardMode = String( - orchestrator.fileReservationGuardMode - ?? out.fileReservationGuardMode - ).trim(); - if (reservationGuardMode === "off" || reservationGuardMode === "warn" || reservationGuardMode === "block") { - out.fileReservationGuardMode = reservationGuardMode; - } - return out; - }; - - const emit = (event: Omit<OrchestratorEvent, "at">) => { - onEvent?.({ - ...event, - at: nowIso() - }); - }; - - const appendTimelineEvent = (args: { - runId: string; - stepId?: string | null; - attemptId?: string | null; - claimId?: string | null; - eventType: string; - reason: string; - detail?: Record<string, unknown> | null; - }): OrchestratorTimelineEvent => { - const id = randomUUID(); - const createdAt = nowIso(); - db.run( - ` - insert into orchestrator_timeline_events( - id, - project_id, - run_id, - step_id, - attempt_id, - claim_id, - event_type, - reason, - detail_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - projectId, - args.runId, - args.stepId ?? null, - args.attemptId ?? null, - args.claimId ?? null, - args.eventType, - args.reason, - args.detail ? JSON.stringify(args.detail) : null, - createdAt - ] - ); - return { - id, - runId: args.runId, - stepId: args.stepId ?? null, - attemptId: args.attemptId ?? null, - claimId: args.claimId ?? null, - eventType: args.eventType, - reason: args.reason, - detail: args.detail ?? null, - createdAt - }; - }; - - const appendRunNarrative = (runId: string, stepKey: string, summary: string): void => { - const runRow = getRunRow(runId); - if (!runRow) return; - const meta = parseJsonRecord(runRow.metadata_json) ?? {}; - const narrative: Array<{ stepKey: string; summary: string; at: string }> = Array.isArray(meta.runNarrative) - ? (meta.runNarrative as Array<{ stepKey: string; summary: string; at: string }>) - : []; - narrative.push({ stepKey, summary, at: new Date().toISOString() }); - // Keep only the last 20 entries - if (narrative.length > 20) narrative.splice(0, narrative.length - 20); - const updatedMeta = { ...meta, runNarrative: narrative }; - db.run( - `update orchestrator_runs set metadata_json = ?, updated_at = ? where id = ? and project_id = ?`, - [JSON.stringify(updatedMeta), nowIso(), runId, projectId] - ); - }; - - const persistRuntimeEvent = (args: { - runId: string; - stepId?: string | null; - attemptId?: string | null; - sessionId?: string | null; - eventType: OrchestratorRuntimeEventType; - eventKey?: string | null; - occurredAt?: string | null; - payload?: Record<string, unknown> | null; - }): OrchestratorRuntimeBusEvent => { - const createdAt = nowIso(); - const occurredAt = normalizeIsoTimestamp(args.occurredAt, createdAt); - const baseKey = `${args.runId}:${args.stepId ?? "none"}:${args.attemptId ?? "none"}:${args.sessionId ?? "none"}:${args.eventType}:${occurredAt}`; - const eventKey = String(args.eventKey ?? baseKey).trim() || baseKey; - const existing = db.get<RuntimeEventRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - session_id, - event_type, - event_key, - occurred_at, - payload_json, - created_at - from orchestrator_runtime_events - where project_id = ? - and event_key = ? - limit 1 - `, - [projectId, eventKey] - ); - if (existing) { - return toRuntimeEvent(existing); - } - - const eventId = randomUUID(); - db.run( - ` - insert into orchestrator_runtime_events( - id, - project_id, - run_id, - step_id, - attempt_id, - session_id, - event_type, - event_key, - occurred_at, - payload_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - eventId, - projectId, - args.runId, - args.stepId ?? null, - args.attemptId ?? null, - args.sessionId ?? null, - args.eventType, - eventKey, - occurredAt, - args.payload ? JSON.stringify(args.payload) : null, - createdAt - ] - ); - const row = db.get<RuntimeEventRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - session_id, - event_type, - event_key, - occurred_at, - payload_json, - created_at - from orchestrator_runtime_events - where project_id = ? - and event_key = ? - limit 1 - `, - [projectId, eventKey] - ); - if (!row) { - throw new Error(`Failed to persist runtime event: ${args.eventType}`); - } - return toRuntimeEvent(row); - }; - - const listTimelineRows = (args: { runId: string; limit?: number }): TimelineRow[] => { - const limitRaw = Number(args.limit ?? 200); - const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(MAX_TIMELINE_LIMIT, Math.floor(limitRaw))) : 200; - return db.all<TimelineRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - claim_id, - event_type, - reason, - detail_json, - created_at - from orchestrator_timeline_events - where project_id = ? - and run_id = ? - order by created_at desc - limit ? - `, - [projectId, args.runId, limit] - ); - }; - - const listRuntimeEventRows = (args: { - runId?: string; - attemptId?: string; - sessionId?: string; - eventTypes?: OrchestratorRuntimeEventType[]; - since?: string | null; - limit?: number; - }): RuntimeEventRow[] => { - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (args.runId) { - where.push("run_id = ?"); - params.push(args.runId); - } - if (args.attemptId) { - where.push("attempt_id = ?"); - params.push(args.attemptId); - } - if (args.sessionId) { - where.push("session_id = ?"); - params.push(args.sessionId); - } - if (args.eventTypes && args.eventTypes.length > 0) { - const normalizedTypes = [...new Set(args.eventTypes.map((entry) => normalizeRuntimeEventType(String(entry))))]; - const placeholders = normalizedTypes.map(() => "?").join(", "); - where.push(`event_type in (${placeholders})`); - params.push(...normalizedTypes); - } - if (args.since && String(args.since).trim().length > 0) { - where.push("occurred_at >= ?"); - params.push(normalizeIsoTimestamp(args.since, nowIso())); - } - const limitRaw = Number(args.limit ?? 200); - const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(5_000, Math.floor(limitRaw))) : 200; - return db.all<RuntimeEventRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - session_id, - event_type, - event_key, - occurred_at, - payload_json, - created_at - from orchestrator_runtime_events - where ${where.join(" and ")} - order by occurred_at desc, created_at desc - limit ? - `, - [...params, limit] - ); - }; - - const getRunRow = (runId: string): RunRow | null => - db.get<RunRow>( - ` - select - id, - mission_id, - project_id, - status, - context_profile, - scheduler_state, - runtime_cursor_json, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - from orchestrator_runs - where id = ? - and project_id = ? - limit 1 - `, - [runId, projectId] - ); - - const getStepRow = (stepId: string): StepRow | null => - db.get<StepRow>( - ` - select - id, - run_id, - mission_step_id, - step_key, - step_index, - title, - lane_id, - status, - join_policy, - quorum_count, - dependency_step_ids_json, - retry_limit, - retry_count, - last_attempt_id, - policy_json, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - from orchestrator_steps - where id = ? - and project_id = ? - limit 1 - `, - [stepId, projectId] - ); - - const listStepRows = (runId: string): StepRow[] => - db.all<StepRow>( - ` - select - id, - run_id, - mission_step_id, - step_key, - step_index, - title, - lane_id, - status, - join_policy, - quorum_count, - dependency_step_ids_json, - retry_limit, - retry_count, - last_attempt_id, - policy_json, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - from orchestrator_steps - where run_id = ? - and project_id = ? - order by step_index asc, created_at asc - `, - [runId, projectId] - ); - - const getAttemptRow = (attemptId: string): AttemptRow | null => - db.get<AttemptRow>( - ` - select - id, - run_id, - step_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - from orchestrator_attempts - where id = ? - and project_id = ? - limit 1 - `, - [attemptId, projectId] - ); - - const listAttemptRows = (runId: string): AttemptRow[] => - db.all<AttemptRow>( - ` - select - id, - run_id, - step_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - from orchestrator_attempts - where run_id = ? - and project_id = ? - order by created_at desc - `, - [runId, projectId] - ); - - const insertHandoff = (args: { - missionId: string; - missionStepId: string | null; - runId: string | null; - stepId: string | null; - attemptId: string | null; - handoffType: string; - producer: string; - payload: Record<string, unknown>; - }): MissionStepHandoff => { - const id = randomUUID(); - const createdAt = nowIso(); - db.run( - ` - insert into mission_step_handoffs( - id, - project_id, - mission_id, - mission_step_id, - run_id, - step_id, - attempt_id, - handoff_type, - producer, - payload_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - projectId, - args.missionId, - args.missionStepId, - args.runId, - args.stepId, - args.attemptId, - args.handoffType, - args.producer, - JSON.stringify(args.payload), - createdAt - ] - ); - return { - id, - missionId: args.missionId, - missionStepId: args.missionStepId, - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - handoffType: args.handoffType, - producer: args.producer, - payload: args.payload, - createdAt - }; - }; - - const updateRunStatus = (runId: string, status: OrchestratorRunStatus, patch: Record<string, SqlValue> = {}) => { - const existing = getRunRow(runId); - if (!existing) throw new Error(`Run not found: ${runId}`); - const updatedAt = nowIso(); - const startedAt = (status === "active" || status === "bootstrapping") && !existing.started_at ? updatedAt : existing.started_at; - const completedAt = TERMINAL_RUN_STATUSES.has(status) ? updatedAt : null; - const hasPatchField = (field: string) => Object.prototype.hasOwnProperty.call(patch, field); - db.run( - ` - update orchestrator_runs - set status = ?, - scheduler_state = ?, - runtime_cursor_json = ?, - last_error = ?, - metadata_json = ?, - started_at = ?, - completed_at = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [ - status, - hasPatchField("scheduler_state") ? patch.scheduler_state : existing.scheduler_state, - hasPatchField("runtime_cursor_json") ? patch.runtime_cursor_json : existing.runtime_cursor_json, - hasPatchField("last_error") ? patch.last_error : existing.last_error, - hasPatchField("metadata_json") ? patch.metadata_json : existing.metadata_json, - startedAt, - completedAt, - updatedAt, - runId, - projectId - ] - ); - emit({ type: "orchestrator-run-updated", runId, reason: "status_updated" }); - appendTimelineEvent({ - runId, - eventType: "run_status_changed", - reason: "status_updated", - detail: { - from: normalizeRunStatus(existing.status), - to: status, - schedulerState: patch.scheduler_state ?? existing.scheduler_state - } - }); - }; - - const runCanStartAttempts = (status: OrchestratorRunStatus): boolean => - status === "queued" || status === "bootstrapping" || status === "active"; - - const hasNonTerminalExecutionWork = (steps: OrchestratorStep[]): boolean => - filterExecutionSteps(steps).some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); - - const isPlannerPlanMissingIntervention = (metadata: Record<string, unknown> | null): boolean => { - const reasonCode = typeof metadata?.reasonCode === "string" ? metadata.reasonCode.trim() : ""; - return reasonCode === "planner_plan_missing"; - }; - - const isPlanningRecoveryStep = (step: OrchestratorStep): boolean => { - const metadata = asRecord(step.metadata); - const stepType = typeof metadata?.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - const taskType = typeof metadata?.taskType === "string" ? metadata.taskType.trim().toLowerCase() : ""; - const phaseKey = typeof metadata?.phaseKey === "string" ? metadata.phaseKey.trim().toLowerCase() : ""; - return stepType === "planning" || stepType === "analysis" || taskType === "planning" || phaseKey === "planning"; - }; - - const hasReadyPlannerPlanRecoveryStep = (runId: string): boolean => - filterExecutionSteps(listStepRows(runId).map(toStep)).some( - (step) => step.status === "ready" && isPlanningRecoveryStep(step), - ); - - const getRunInterventionGateState = (run: OrchestratorRun): { - blocked: boolean; - recoveryMode: "planner_plan_missing" | null; - } => { - const missionRow = db.get<{ status: string | null }>( - "select status from missions where id = ? and project_id = ? limit 1", - [run.missionId, projectId], - ); - const missionStatus = String(missionRow?.status ?? "").trim(); - const openInterventions = db.all<{ metadata_json: string | null }>( - ` - select metadata_json - from mission_interventions - where project_id = ? - and mission_id = ? - and status = 'open' - `, - [projectId, run.missionId], - ); - const hasRelevantOpenIntervention = openInterventions.some((row) => { - const metadata = parseJsonRecord(row.metadata_json); - const interventionRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - return interventionRunId.length === 0 || interventionRunId === run.id; - }); - const relevantOpenInterventions = openInterventions.filter((row) => { - const metadata = parseJsonRecord(row.metadata_json); - const interventionRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - return interventionRunId.length === 0 || interventionRunId === run.id; - }); - - if (missionStatus !== "intervention_required" && !hasRelevantOpenIntervention) { - return { blocked: false, recoveryMode: null }; - } - - const onlyPlannerPlanMissing = - relevantOpenInterventions.length > 0 - && relevantOpenInterventions.every((row) => isPlannerPlanMissingIntervention(parseJsonRecord(row.metadata_json))); - if (onlyPlannerPlanMissing && hasReadyPlannerPlanRecoveryStep(run.id)) { - return { blocked: false, recoveryMode: "planner_plan_missing" }; - } - - return { blocked: true, recoveryMode: null }; - }; - - const ensurePlannerQuestionIntervention = (args: { - missionId: string; - runId: string; - step: OrchestratorStep; - attempt: OrchestratorAttempt; - question: string; - questionLink: ReturnType<typeof buildQuestionThreadLink>; - detectedAt: string; - sessionId: string | null; - phaseKey: string; - stepType: string; - reasonCode: PlannerQuestionReasonCode; - source: PlannerQuestionReasonCode; - requestedAction: string; - pauseReason: string; - }): string | null => { - const missionId = args.missionId.trim(); - const question = args.question.trim(); - if (!missionId || !question) return null; - const existingRows = db.all<{ id: string; metadata_json: string | null }>( - ` - select id, metadata_json - from mission_interventions - where project_id = ? - and mission_id = ? - and status = 'open' - and intervention_type = 'manual_input' - `, - [projectId, missionId], - ); - for (const row of existingRows) { - const metadata = parseJsonRecord(row.metadata_json); - if (metadata?.reasonCode === args.reasonCode && metadata?.attemptId === args.attempt.id) { - return row.id; - } - } - - const id = randomUUID(); - const title = question.length > 96 ? "Planner question ready" : `Question: ${question}`; - const metadata = { - source: args.source, - reasonCode: args.reasonCode, - question, - runId: args.runId, - stepId: args.step.id, - stepKey: args.step.stepKey, - attemptId: args.attempt.id, - sessionId: args.sessionId, - phaseKey: args.phaseKey || null, - stepType: args.stepType || null, - threadId: args.questionLink.threadId, - messageId: args.questionLink.messageId, - replyTo: args.questionLink.replyTo, - }; - db.run( - ` - insert into mission_interventions( - id, - mission_id, - project_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - metadata_json, - created_at, - updated_at, - resolved_at - ) values (?, ?, ?, 'manual_input', 'open', null, ?, ?, ?, null, ?, ?, ?, ?, null) - `, - [ - id, - missionId, - projectId, - title, - question, - args.requestedAction, - args.step.laneId ?? null, - JSON.stringify(metadata), - args.detectedAt, - args.detectedAt, - ], - ); - db.run( - ` - update missions - set status = 'intervention_required', - updated_at = ? - where id = ? - and project_id = ? - and status in ('queued', 'planning', 'in_progress') - `, - [args.detectedAt, missionId, projectId], - ); - - const runRow = getRunRow(args.runId); - if (runRow) { - const run = toRun(runRow); - if (!TERMINAL_RUN_STATUSES.has(run.status) && run.status !== "paused") { - updateRunStatus(args.runId, "paused", { - last_error: `${args.pauseReason}: ${question.slice(0, 140)}`, - metadata_json: JSON.stringify({ - ...(parseJsonRecord(runRow.metadata_json) ?? {}), - pendingPlannerQuestion: { - interventionId: id, - stepId: args.step.id, - stepKey: args.step.stepKey, - attemptId: args.attempt.id, - question, - }, - }), - }); - appendTimelineEvent({ - runId: args.runId, - eventType: "run_paused", - reason: args.reasonCode, - detail: { - interventionId: id, - question, - reasonCode: args.reasonCode, - }, - }); - } - } - - return id; - }; - - const extractResolvedPlannerChatQuestion = ( - events: AgentChatEventEnvelope[], - ): { - itemId: string; - question: string; - requestedAt: string; - resolvedAt: string; - resolutionKind: "answer_provided" | "skip_question"; - } | null => { - const pendingQuestions = new Map<string, { question: string; requestedAt: string }>(); - const resolvedItems = new Map<string, { resolvedAt: string; resolutionKind: "answer_provided" | "skip_question" }>(); - - const readQuestion = (event: AgentChatEventEnvelope["event"]): string | null => { - if (event.type !== "approval_request") return null; - if (event.kind !== "tool_call") return null; - const detail = asRecord(event.detail); - const request = asRecord(detail?.request); - if (!request) return null; - const kind = typeof request.kind === "string" ? request.kind.trim().toLowerCase() : ""; - if (kind === "plan_approval" || kind === "approval" || kind === "permissions") return null; - const questions = Array.isArray(request.questions) ? request.questions : []; - const hasQuestionPayload = - kind === "question" - || kind === "structured_question" - || questions.some((entry) => { - const question = asRecord(entry); - return typeof question?.question === "string" && question.question.trim().length > 0; - }); - if (!hasQuestionPayload) return null; - const firstQuestion = questions.map((entry) => asRecord(entry)).find((entry) => - typeof entry?.question === "string" && entry.question.trim().length > 0 - ); - const questionText = - typeof firstQuestion?.question === "string" && firstQuestion.question.trim().length > 0 - ? firstQuestion.question.trim() - : typeof request.description === "string" && request.description.trim().length > 0 - ? request.description.trim() - : event.description.trim(); - return questionText.length > 0 ? questionText : null; - }; - - for (const envelope of events) { - const event = envelope.event; - if (event.type === "approval_request") { - const question = readQuestion(event); - if (!question) continue; - const itemId = typeof event.itemId === "string" ? event.itemId.trim() : ""; - if (!itemId) continue; - const requestedAt = typeof envelope.timestamp === "string" && envelope.timestamp.trim().length > 0 - ? envelope.timestamp - : nowIso(); - const resolvedItem = resolvedItems.get(itemId); - if (resolvedItem) { - return { itemId, question, requestedAt, ...resolvedItem }; - } - pendingQuestions.set(itemId, { question, requestedAt }); - continue; - } - if (event.type !== "pending_input_resolved" || (event.resolution !== "accepted" && event.resolution !== "declined")) continue; - const itemId = typeof event.itemId === "string" ? event.itemId.trim() : ""; - if (!itemId) continue; - const resolvedAt = typeof envelope.timestamp === "string" && envelope.timestamp.trim().length > 0 - ? envelope.timestamp - : nowIso(); - const resolutionKind = event.resolution === "accepted" ? "answer_provided" : "skip_question"; - const pending = pendingQuestions.get(itemId); - if (pending) { - return { itemId, question: pending.question, requestedAt: pending.requestedAt, resolvedAt, resolutionKind }; - } - resolvedItems.set(itemId, { resolvedAt, resolutionKind }); - } - - return null; - }; - - const readResolvedPlannerChatQuestion = (args: { - sessionId: string | null; - transcriptPath: string; - }): { - itemId: string; - question: string; - requestedAt: string; - resolvedAt: string; - resolutionKind: "answer_provided" | "skip_question"; - } | null => { - const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; - if (!sessionId) return null; - const events: AgentChatEventEnvelope[] = []; - try { - const snapshot = agentChatService?.getChatEventHistory(sessionId, { maxEvents: 20_000 }); - if (snapshot?.sessionFound !== false && Array.isArray(snapshot?.events)) { - events.push(...snapshot.events.filter((entry) => entry.sessionId === sessionId)); - } - } catch { - // Fall back to the persisted transcript path below. - } - const fromHistory = extractResolvedPlannerChatQuestion(events); - if (fromHistory) return fromHistory; - - const transcriptPath = args.transcriptPath.trim(); - if (transcriptPath && fs.existsSync(transcriptPath)) { - try { - events.push( - ...parseAgentChatTranscript(fs.readFileSync(transcriptPath, "utf8")) - .filter((entry) => entry.sessionId === sessionId), - ); - } catch { - // A malformed or missing transcript should not block normal planner completion. - } - } - return extractResolvedPlannerChatQuestion(events); - }; - - const ensureResolvedPlannerChatQuestionIntervention = (args: { - missionId: string; - runId: string; - step: OrchestratorStep; - attempt: OrchestratorAttempt; - sessionId: string | null; - phaseKey: string; - stepType: string; - itemId: string; - question: string; - requestedAt: string; - resolvedAt: string; - resolutionKind: "answer_provided" | "skip_question"; - }): string | null => { - const missionId = args.missionId.trim(); - const itemId = args.itemId.trim(); - const question = args.question.trim(); - if (!missionId || !itemId || !question) return null; - const resolutionNote = args.resolutionKind === "skip_question" ? "Skipped in ADE chat." : "Answered in ADE chat."; - const existingRows = db.all<{ id: string; status: string; metadata_json: string | null }>( - ` - select id, status, metadata_json - from mission_interventions - where project_id = ? - and mission_id = ? - and intervention_type = 'manual_input' - `, - [projectId, missionId], - ); - const isRelatedResolvedPlannerQuestion = (metadata: Record<string, unknown> | null): boolean => { - const reasonCode = typeof metadata?.reasonCode === "string" ? metadata.reasonCode.trim() : ""; - if ( - reasonCode !== "planner_chat_question" - && reasonCode !== "planner_natural_question" - && reasonCode !== "planner_required_question_missing" - ) { - return false; - } - if (metadata?.attemptId !== args.attempt.id) return false; - const metadataRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - if (metadataRunId && metadataRunId !== args.runId) return false; - const metadataStepId = typeof metadata?.stepId === "string" ? metadata.stepId.trim() : ""; - if (metadataStepId && metadataStepId !== args.step.id) return false; - if (reasonCode === "planner_chat_question") { - const metadataItemId = typeof metadata?.itemId === "string" ? metadata.itemId.trim() : ""; - return !metadataItemId || metadataItemId === itemId; - } - const metadataQuestion = typeof metadata?.question === "string" ? metadata.question.trim() : ""; - return !metadataQuestion || metadataQuestion === question; - }; - const resolveExistingPlannerQuestion = (row: { id: string; status: string }): void => { - if (row.status === "resolved") return; - db.run( - ` - update mission_interventions - set status = 'resolved', - resolution_kind = ?, - resolution_note = ?, - resolved_at = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [ - args.resolutionKind, - resolutionNote, - args.resolvedAt, - args.resolvedAt, - row.id, - projectId, - ], - ); - }; - let existingChatQuestionId: string | null = null; - for (const row of existingRows) { - const metadata = parseJsonRecord(row.metadata_json); - if (metadata?.reasonCode === "planner_chat_question" && metadata?.attemptId === args.attempt.id && metadata?.itemId === itemId) { - existingChatQuestionId = row.id; - } - if (isRelatedResolvedPlannerQuestion(metadata)) { - resolveExistingPlannerQuestion(row); - } - } - if (existingChatQuestionId) return existingChatQuestionId; - - const id = randomUUID(); - const title = question.length > 96 ? "Planner question answered" : `Question: ${question}`; - const metadata = { - source: "request_user_input", - reasonCode: "planner_chat_question", - questionOwnerKind: "planner", - question, - runId: args.runId, - stepId: args.step.id, - stepKey: args.step.stepKey, - attemptId: args.attempt.id, - sessionId: args.sessionId, - itemId, - phase: args.phaseKey || null, - phaseKey: args.phaseKey || null, - stepType: args.stepType || null, - }; - db.run( - ` - insert into mission_interventions( - id, - mission_id, - project_id, - intervention_type, - status, - resolution_kind, - title, - body, - requested_action, - resolution_note, - lane_id, - metadata_json, - created_at, - updated_at, - resolved_at - ) values (?, ?, ?, 'manual_input', 'resolved', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - missionId, - projectId, - args.resolutionKind, - title, - question, - args.resolutionKind === "skip_question" - ? "Planner question was skipped in the ADE chat thread." - : "Planner question was answered in the ADE chat thread.", - resolutionNote, - args.step.laneId ?? null, - JSON.stringify(metadata), - args.requestedAt, - args.resolvedAt, - args.resolvedAt, - ], - ); - return id; - }; - - const expireClaims = () => { - const now = nowIso(); - const expiring = db.all<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where project_id = ? - and state = 'active' - and expires_at <= ? - `, - [projectId, now] - ); - db.run( - ` - update orchestrator_claims - set state = 'expired', - released_at = ? - where project_id = ? - and state = 'active' - and expires_at <= ? - `, - [now, projectId, now] - ); - for (const row of expiring) { - appendTimelineEvent({ - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - claimId: row.id, - eventType: "claim_expired", - reason: "lease_expired", - detail: { - ownerId: row.owner_id, - scopeKind: row.scope_kind, - scopeValue: row.scope_value, - expiresAt: row.expires_at - } - }); - emit({ type: "orchestrator-claim-updated", runId: row.run_id, claimId: row.id, reason: "expired" }); - } - }; - - const acquireClaim = (args: { - runId: string; - stepId: string | null; - attemptId: string | null; - ownerId: string; - scopeKind: OrchestratorClaimScope; - scopeValue: string; - ttlMs: number; - policy: Record<string, unknown>; - }): OrchestratorClaim | null => { - expireClaims(); - const normalizedScopeValue = normalizeClaimScopeValue({ - scopeKind: args.scopeKind, - scopeValue: args.scopeValue - }); - if (!normalizedScopeValue) return null; - const conflict = findActiveClaimConflict({ - scopeKind: args.scopeKind, - scopeValue: normalizedScopeValue, - ignoreAttemptId: args.attemptId - }); - if (conflict) return null; - const id = randomUUID(); - const acquiredAt = nowIso(); - const expiresAt = new Date(Date.now() + Math.max(1_000, Math.floor(args.ttlMs))).toISOString(); - try { - db.run( - ` - insert into orchestrator_claims( - id, - project_id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, null, ?, ?) - `, - [ - id, - projectId, - args.runId, - args.stepId, - args.attemptId, - args.ownerId, - args.scopeKind, - normalizedScopeValue, - acquiredAt, - acquiredAt, - expiresAt, - JSON.stringify(args.policy), - JSON.stringify({}) - ] - ); - const row = db.get<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where id = ? - and project_id = ? - limit 1 - `, - [id, projectId] - ); - if (!row) return null; - const claim = toClaim(row); - emit({ type: "orchestrator-claim-updated", runId: args.runId, claimId: claim.id, reason: "acquired" }); - appendTimelineEvent({ - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - claimId: claim.id, - eventType: "claim_acquired", - reason: "claim_acquired", - detail: { - ownerId: claim.ownerId, - scopeKind: claim.scopeKind, - scopeValue: claim.scopeValue, - expiresAt: claim.expiresAt - } - }); - - // Task-scope claims: mark the step as claimed by updating its metadata - if (args.scopeKind === "task" && args.stepId) { - const stepRow = getStepRow(args.stepId); - if (stepRow) { - const stepMeta = parseJsonRecord(stepRow.metadata_json) ?? {}; - const updatedMeta = { - ...stepMeta, - claimedBy: args.ownerId, - claimedAt: acquiredAt, - taskClaimId: claim.id - }; - db.run( - ` - update orchestrator_steps - set metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(updatedMeta), acquiredAt, args.stepId, args.runId, projectId] - ); - emit({ type: "orchestrator-step-updated", runId: args.runId, stepId: args.stepId, reason: "task_claimed" }); - } - } - - return claim; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.toLowerCase().includes("constraint")) return null; - throw error; - } - }; - - const releaseClaimsForAttempt = (args: { - attemptId: string; - state?: Exclude<OrchestratorClaimState, "active">; - }): number => { - const nextState = args.state ?? "released"; - const releasedAt = nowIso(); - const releasable = db.all<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where project_id = ? - and attempt_id = ? - and state = 'active' - `, - [projectId, args.attemptId] - ); - db.run( - ` - update orchestrator_claims - set state = ?, - released_at = ? - where project_id = ? - and attempt_id = ? - and state = 'active' - `, - [nextState, releasedAt, projectId, args.attemptId] - ); - const countRow = db.get<{ count: number }>( - ` - select count(*) as count - from orchestrator_claims - where project_id = ? - and attempt_id = ? - and state = ? - `, - [projectId, args.attemptId, nextState] - ); - const count = Number(countRow?.count ?? 0); - if (count > 0) { - emit({ type: "orchestrator-claim-updated", attemptId: args.attemptId, reason: "released" }); - for (const claim of releasable) { - appendTimelineEvent({ - runId: claim.run_id, - stepId: claim.step_id, - attemptId: claim.attempt_id, - claimId: claim.id, - eventType: nextState === "expired" ? "claim_expired" : "claim_released", - reason: nextState === "expired" ? "released_as_expired" : "released", - detail: { - ownerId: claim.owner_id, - scopeKind: claim.scope_kind, - scopeValue: claim.scope_value, - releasedAt, - state: nextState - } - }); - } - } - return count; - }; - - const normalizeClaimScopeValue = (args: { - scopeKind: OrchestratorClaimScope; - scopeValue: string; - }): string | null => { - const raw = String(args.scopeValue ?? "").trim(); - if (!raw.length) return null; - // Task claims use step ID as scope value — pass through directly - if (args.scopeKind === "task") return raw; - if (args.scopeKind !== "file") return raw; - return normalizeFileClaimScopeValue(projectRoot, raw); - }; - - const findActiveClaimConflict = (args: { - scopeKind: OrchestratorClaimScope; - scopeValue: string; - ignoreAttemptId?: string | null; - }): { conflict: ClaimRow; reason: string } | null => { - const ignoreAttemptId = String(args.ignoreAttemptId ?? "").trim(); - if (args.scopeKind === "file") { - const activeFileClaims = db.all<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where project_id = ? - and state = 'active' - and scope_kind = 'file' - `, - [projectId] - ); - for (const claim of activeFileClaims) { - if (ignoreAttemptId.length > 0 && claim.attempt_id === ignoreAttemptId) continue; - if (!doFileClaimsOverlap(args.scopeValue, claim.scope_value)) continue; - return { - conflict: claim, - reason: `overlapping_file_scope:${args.scopeValue}<->${claim.scope_value}` - }; - } - return null; - } - - const row = db.get<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where project_id = ? - and state = 'active' - and scope_kind = ? - and scope_value = ? - ${ignoreAttemptId.length > 0 ? "and coalesce(attempt_id, '') != ?" : ""} - limit 1 - `, - ignoreAttemptId.length > 0 - ? [projectId, args.scopeKind, args.scopeValue, ignoreAttemptId] - : [projectId, args.scopeKind, args.scopeValue] - ); - if (!row) return null; - return { - conflict: row, - reason: "exact_scope_collision" - }; - }; - - const collectTouchedRepoPaths = async (args: { - laneId?: string | null; - result?: Partial<OrchestratorAttemptResultEnvelope> | null; - metadata?: Record<string, unknown> | null; - }): Promise<{ touchedPaths: string[]; rawPaths: string[] }> => { - const rawPaths: string[] = []; - const touched = new Set<string>(); - const pushPath = (value: unknown) => { - if (typeof value !== "string") return; - const raw = value.trim(); - if (!raw.length) return; - rawPaths.push(raw); - const normalized = normalizeRepoRelativePath(projectRoot, raw); - if (!normalized) return; - touched.add(normalized); - }; - const parsePathArray = (value: unknown) => { - if (!Array.isArray(value)) return; - for (const entry of value) { - if (typeof entry === "string") { - const renameSplit = entry.split(/\s*->\s*/); - if (renameSplit.length === 2) { - pushPath(renameSplit[0]); - pushPath(renameSplit[1]); - } else { - pushPath(entry); - } - continue; - } - if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; - const record = entry as Record<string, unknown>; - pushPath(record.path); - pushPath(record.file); - pushPath(record.from); - pushPath(record.to); - pushPath(record.oldPath); - pushPath(record.newPath); - } - }; - - const outputs = - args.result?.outputs && typeof args.result.outputs === "object" && !Array.isArray(args.result.outputs) - ? (args.result.outputs as Record<string, unknown>) - : {}; - parsePathArray(outputs.modifiedFiles ?? outputs.modified_files ?? outputs.filesModified ?? outputs.files_modified); - parsePathArray(outputs.changedFiles ?? outputs.changed_files); - parsePathArray(outputs.renamedFiles ?? outputs.renamed_files); - parsePathArray(args.metadata?.changedFiles ?? args.metadata?.changed_files); - parsePathArray(args.metadata?.modifiedFiles ?? args.metadata?.modified_files); - parsePathArray(args.metadata?.renamedFiles ?? args.metadata?.renamed_files); - - const laneId = typeof args.laneId === "string" ? args.laneId.trim() : ""; - if (laneId.length > 0) { - const laneRow = db.get<{ worktree_path: string | null }>( - ` - select worktree_path - from lanes - where id = ? - and project_id = ? - limit 1 - `, - [laneId, projectId] - ); - const laneRoot = typeof laneRow?.worktree_path === "string" && laneRow.worktree_path.trim().length - ? laneRow.worktree_path.trim() - : projectRoot; - try { - const status = await runGit(["status", "--porcelain=v1", "--untracked-files=all"], { - cwd: laneRoot, - timeoutMs: 15_000, - }); - if (status.exitCode === 0 && status.stdout.trim().length > 0) { - const normalizePorcelainPath = (value: string): string => { - const trimmed = value.trim(); - if (!trimmed.startsWith("\"") || !trimmed.endsWith("\"")) return trimmed; - return trimmed - .slice(1, -1) - .replace(/\\\"/g, "\"") - .replace(/\\\\/g, "\\"); - }; - for (const line of status.stdout.split(/\r?\n/)) { - if (line.length < 4) continue; - const payload = line.slice(3).trim(); - if (!payload.length) continue; - if (payload.includes(" -> ")) { - const [left, right] = payload.split(/\s+->\s+/, 2); - for (const entry of [left, right]) { - if (!entry) continue; - const normalizedToken = normalizePorcelainPath(entry); - rawPaths.push(normalizedToken); - const normalizedPath = normalizeRepoRelativePath(projectRoot, normalizedToken); - if (normalizedPath) touched.add(normalizedPath); - } - continue; - } - const normalizedToken = normalizePorcelainPath(payload); - rawPaths.push(normalizedToken); - const normalizedPath = normalizeRepoRelativePath(projectRoot, normalizedToken); - if (normalizedPath) touched.add(normalizedPath); - } - } - } catch { - // Best-effort only; runtime metadata remains the primary source. - } - } - return { - touchedPaths: [...touched].sort((a, b) => a.localeCompare(b)), - rawPaths - }; - }; - - const evaluateFileReservationViolations = async (args: { - step: OrchestratorStep; - result?: Partial<OrchestratorAttemptResultEnvelope> | null; - metadata?: Record<string, unknown> | null; - }): Promise<{ - normalizedScopes: string[]; - touchedPaths: string[]; - violations: string[]; - rawPaths: string[]; - }> => { - const stepPolicy = resolveStepPolicy(args.step); - const fileScopes = (stepPolicy.claimScopes ?? []) - .filter((scope) => scope.scopeKind === "file") - .map((scope) => normalizeClaimScopeValue({ scopeKind: "file", scopeValue: scope.scopeValue })) - .filter((scope): scope is string => Boolean(scope)); - if (!fileScopes.length) { - return { - normalizedScopes: [], - touchedPaths: [], - violations: [], - rawPaths: [] - }; - } - const touched = await collectTouchedRepoPaths({ - laneId: args.step.laneId, - result: args.result, - metadata: args.metadata - }); - const violations = touched.touchedPaths.filter((repoPath) => { - for (const scope of fileScopes) { - if (doesFileClaimMatchPath(scope, repoPath)) return false; - } - return true; - }); - return { - normalizedScopes: fileScopes, - touchedPaths: touched.touchedPaths, - violations, - rawPaths: touched.rawPaths - }; - }; - - const isPermanentlyBlockedStep = (step: OrchestratorStep): boolean => { - if (step.status !== "blocked") return false; - if (step.metadata?.blockedSticky === true) return true; - return step.metadata?.blockedErrorClass === "policy"; - }; - - const isTerminalForDependencyGate = (step: OrchestratorStep | null): boolean => { - if (!step) return false; - if (TERMINAL_STEP_STATUSES.has(step.status)) return true; - return isPermanentlyBlockedStep(step); - }; - - const supersedeAutoSpawnedValidationStepIfTargetPassed = (args: { - runId: string; - step: OrchestratorStep; - reason: string; - }): boolean => { - const stepMetadata = asRecord(args.step.metadata); - const targetStepId = getAutoSpawnedValidationTargetStepId(stepMetadata); - if (!targetStepId) return false; - const targetRow = getStepRow(targetStepId); - if (!targetRow || targetRow.run_id !== args.runId) return false; - const targetStep = toStep(targetRow); - if (!hasPassingValidation(asRecord(targetStep.metadata))) return false; - - const supersededAt = nowIso(); - const openThreadRow = db.get<{ count: number }>( - ` - select count(*) as count - from orchestrator_chat_threads - where run_id = ? - and step_id = ? - and status <> 'closed' - `, - [args.runId, args.step.id], - ); - const openThreadCount = Number(openThreadRow?.count ?? 0); - db.run( - ` - update orchestrator_chat_threads - set status = 'closed', - updated_at = ? - where run_id = ? - and step_id = ? - and status <> 'closed' - `, - [supersededAt, args.runId, args.step.id], - ); - if (!["pending", "ready", "blocked", "failed"].includes(args.step.status)) { - if (openThreadCount > 0) { - emit({ - type: "orchestrator-step-updated", - runId: args.runId, - stepId: args.step.id, - reason: "target_validation_thread_closed", - }); - } - return openThreadCount > 0; - } - - const nextMetadata = { - ...(stepMetadata ?? {}), - superseded: true, - supersededAt, - supersededReason: "target_validation_already_passed", - targetValidationAlreadyPassed: true, - }; - db.run( - ` - update orchestrator_steps - set status = 'superseded', - metadata_json = ?, - updated_at = ?, - completed_at = coalesce(completed_at, ?) - where id = ? - and run_id = ? - and project_id = ? - and status in ('pending', 'ready', 'blocked', 'failed') - `, - [JSON.stringify(nextMetadata), supersededAt, supersededAt, args.step.id, args.runId, projectId], - ); - emit({ - type: "orchestrator-step-updated", - runId: args.runId, - stepId: args.step.id, - reason: "target_validation_already_passed", - }); - appendTimelineEvent({ - runId: args.runId, - stepId: args.step.id, - eventType: "validation_auto_step_superseded", - reason: args.reason, - detail: { - stepKey: args.step.stepKey, - previousStatus: args.step.status, - targetStepId, - targetStepKey: targetStep.stepKey, - }, - }); - return true; - }; - - const settleStaleAutoSpawnedValidationSteps = (runId: string): void => { - for (const step of listStepRows(runId).map(toStep)) { - supersedeAutoSpawnedValidationStepIfTargetPassed({ - runId, - step, - reason: "target_validation_already_passed", - }); - } - }; - - const closeTerminalWorkerThreadsForRun = (runId: string): void => { - const closedAt = nowIso(); - const openThreadRow = db.get<{ count: number }>( - ` - select count(*) as count - from orchestrator_chat_threads - where run_id = ? - and thread_type = 'worker' - and status <> 'closed' - and step_id in ( - select id - from orchestrator_steps - where run_id = ? - and project_id = ? - and status in ('succeeded', 'failed', 'blocked', 'canceled', 'skipped', 'superseded') - ) - `, - [runId, runId, projectId], - ); - const openThreadCount = Number(openThreadRow?.count ?? 0); - db.run( - ` - update orchestrator_chat_threads - set status = 'closed', - updated_at = ? - where run_id = ? - and thread_type = 'worker' - and status <> 'closed' - and step_id in ( - select id - from orchestrator_steps - where run_id = ? - and project_id = ? - and status in ('succeeded', 'failed', 'blocked', 'canceled', 'skipped', 'superseded') - ) - `, - [closedAt, runId, runId, projectId], - ); - if (openThreadCount > 0) { - emit({ type: "orchestrator-run-updated", runId, reason: "terminal_worker_threads_reconciled" }); - } - }; - - const evaluateDependencyGate = (step: OrchestratorStep, stepsById: Map<string, OrchestratorStep>) => { - if (!step.dependencyStepIds.length) { - return { satisfied: true, permanentlyBlocked: false }; - } - // Advisory dependencies are non-blocking — step proceeds immediately but - // may receive handoff context from upstream steps when they complete. - if (step.joinPolicy === "advisory") { - return { satisfied: true, permanentlyBlocked: false }; - } - const depSteps = step.dependencyStepIds.map((id) => stepsById.get(id) ?? null); - const depStatuses = depSteps.map((dep) => dep?.status ?? "pending"); - const successCount = depStatuses.filter((status) => status === "succeeded" || status === "skipped" || status === "superseded").length; - const allTerminal = depSteps.every((dep) => isTerminalForDependencyGate(dep)); - if (step.joinPolicy === "any_success") { - if (successCount >= 1) return { satisfied: true, permanentlyBlocked: false }; - return { satisfied: false, permanentlyBlocked: allTerminal }; - } - if (step.joinPolicy === "quorum") { - const required = step.quorumCount && step.quorumCount > 0 ? step.quorumCount : Math.max(1, Math.ceil(depStatuses.length / 2)); - if (successCount >= required) return { satisfied: true, permanentlyBlocked: false }; - return { satisfied: false, permanentlyBlocked: allTerminal }; - } - // all_success - const allSucceeded = depStatuses.every((status) => status === "succeeded" || status === "skipped" || status === "superseded"); - if (allSucceeded) return { satisfied: true, permanentlyBlocked: false }; - return { satisfied: false, permanentlyBlocked: allTerminal }; - }; - - const evaluateConfiguredPhaseGate = (args: { - step: OrchestratorStep; - phaseCards: PhaseCard[] | null; - currentPhase: PhaseCard | null; - steps: OrchestratorStep[]; - }): { - satisfied: boolean; - reason: "no_configured_phases" | "step_unphased" | "phase_runtime_missing" | "inactive_phase" | null; - } => { - if (!Array.isArray(args.phaseCards) || args.phaseCards.length === 0) { - return { satisfied: true, reason: "no_configured_phases" }; - } - const stepPhase = resolvePhaseCardForStep(args.step, args.phaseCards); - if (!stepPhase) { - return { satisfied: true, reason: "step_unphased" }; - } - if (!args.currentPhase) { - return { satisfied: false, reason: "phase_runtime_missing" }; - } - return { - satisfied: - stepPhase.phaseKey === args.currentPhase.phaseKey - || canEnterConfiguredPhase(stepPhase, args.phaseCards, args.steps), - reason: - stepPhase.phaseKey === args.currentPhase.phaseKey - || canEnterConfiguredPhase(stepPhase, args.phaseCards, args.steps) - ? null - : "inactive_phase" - }; - }; - - const evaluateValidationDependencyGate = (args: { - step: OrchestratorStep; - stepsById: Map<string, OrchestratorStep>; - phaseCards: PhaseCard[] | null; - }): { - satisfied: boolean; - blockingDependencyIds: string[]; - } => { - if (!args.step.dependencyStepIds.length || args.step.joinPolicy === "advisory") { - return { satisfied: true, blockingDependencyIds: [] }; - } - const stepMetadata = asRecord(args.step.metadata); - if (isValidationLikeStepMetadata(stepMetadata)) { - return { satisfied: true, blockingDependencyIds: [] }; - } - const depSteps = args.step.dependencyStepIds - .map((id) => args.stepsById.get(id) ?? null) - .filter((dep): dep is OrchestratorStep => Boolean(dep)); - const validatedSuccessCount = depSteps.filter((dep) => { - if (dep.status !== "succeeded" && dep.status !== "skipped" && dep.status !== "superseded") return false; - if (!stepRequiresValidation(dep, args.phaseCards)) return true; - return hasPassingValidation(asRecord(dep.metadata)); - }).length; - if (args.step.joinPolicy === "any_success") { - return { satisfied: validatedSuccessCount >= 1, blockingDependencyIds: [] }; - } - if (args.step.joinPolicy === "quorum") { - const required = args.step.quorumCount && args.step.quorumCount > 0 - ? args.step.quorumCount - : Math.max(1, Math.ceil(depSteps.length / 2)); - return { satisfied: validatedSuccessCount >= required, blockingDependencyIds: [] }; - } - const blockingDependencyIds = depSteps - .filter((dep) => dep.status === "succeeded" && stepRequiresValidation(dep, args.phaseCards)) - .filter((dep) => !hasPassingValidation(asRecord(dep.metadata))) - .map((dep) => dep.id); - return { - satisfied: blockingDependencyIds.length === 0, - blockingDependencyIds - }; - }; - - const applyRunPhaseTransition = (args: { - runId: string; - targetPhase: PhaseCard; - reason: string; - source: "kernel_auto_phase_sync" | "kernel_phase_runtime_init"; - }): { - changed: boolean; - currentPhase: PhaseCard; - metadata: Record<string, unknown>; - } | null => { - const runRow = getRunRow(args.runId); - if (!runRow) return null; - const metadata = parseJsonRecord(runRow.metadata_json) ?? {}; - const phaseRuntimeSource = asRecord(metadata.phaseRuntime); - const phaseRuntime: Record<string, unknown> = phaseRuntimeSource ? { ...phaseRuntimeSource } : {}; - const previousPhaseKey = typeof phaseRuntime.currentPhaseKey === "string" ? phaseRuntime.currentPhaseKey.trim() : ""; - const previousPhaseName = typeof phaseRuntime.currentPhaseName === "string" ? phaseRuntime.currentPhaseName.trim() : ""; - if (previousPhaseKey === args.targetPhase.phaseKey && previousPhaseName === args.targetPhase.name) { - return { - changed: false, - currentPhase: args.targetPhase, - metadata, - }; - } - const now = nowIso(); - const transitions = Array.isArray(phaseRuntime.transitions) ? [...phaseRuntime.transitions] : []; - transitions.unshift({ - fromPhaseKey: previousPhaseKey || null, - fromPhaseName: previousPhaseName || null, - toPhaseKey: args.targetPhase.phaseKey, - toPhaseName: args.targetPhase.name, - at: now, - reason: args.reason - }); - const existingPhaseBudgets = asRecord(phaseRuntime.phaseBudgets) ?? {}; - const targetPhaseBudget = asRecord(existingPhaseBudgets[args.targetPhase.phaseKey]); - phaseRuntime.transitions = transitions.slice(0, 64); - phaseRuntime.currentPhaseKey = args.targetPhase.phaseKey; - phaseRuntime.currentPhaseName = args.targetPhase.name; - phaseRuntime.currentPhaseModel = args.targetPhase.model; - phaseRuntime.currentPhaseInstructions = args.targetPhase.instructions; - phaseRuntime.currentPhaseValidation = args.targetPhase.validationGate; - phaseRuntime.currentPhaseBudget = args.targetPhase.budget ?? {}; - phaseRuntime.transitionedAt = now; - phaseRuntime.phaseBudgets = { - ...existingPhaseBudgets, - [args.targetPhase.phaseKey]: { - enteredAt: typeof targetPhaseBudget?.enteredAt === "string" && targetPhaseBudget.enteredAt.trim().length > 0 - ? targetPhaseBudget.enteredAt - : now, - usedTokens: Number.isFinite(Number(targetPhaseBudget?.usedTokens)) - ? Number(targetPhaseBudget?.usedTokens) - : 0, - usedCostUsd: Number.isFinite(Number(targetPhaseBudget?.usedCostUsd)) - ? Number(targetPhaseBudget?.usedCostUsd) - : 0 - } - }; - const updatedMetadata = { - ...metadata, - phaseRuntime - }; - db.run( - `update orchestrator_runs set metadata_json = ?, updated_at = ? where id = ? and project_id = ?`, - [JSON.stringify(updatedMetadata), now, args.runId, projectId] - ); - appendTimelineEvent({ - runId: args.runId, - eventType: "phase_transition", - reason: args.reason, - detail: { - fromPhaseKey: previousPhaseKey || null, - fromPhaseName: previousPhaseName || null, - toPhaseKey: args.targetPhase.phaseKey, - toPhaseName: args.targetPhase.name, - phaseModel: args.targetPhase.model, - phaseValidation: args.targetPhase.validationGate, - phaseBudget: args.targetPhase.budget ?? {}, - transitionedAt: now, - source: args.source - } - }); - emit({ type: "orchestrator-run-updated", runId: args.runId, reason: "phase_transition" }); - emit({ - type: "orchestrator-step-updated", - runId: args.runId, - reason: "phase_transition" - }); - return { - changed: true, - currentPhase: args.targetPhase, - metadata: updatedMetadata, - }; - }; - - const syncConfiguredPhaseRuntime = (runId: string, steps: OrchestratorStep[]): { - phaseCards: PhaseCard[] | null; - currentPhase: PhaseCard | null; - } => { - const runRow = getRunRow(runId); - const runMetadata = runRow ? parseJsonRecord(runRow.metadata_json) : null; - const phaseCards = resolveRunPhaseCardsFromMetadata(runMetadata); - if (!phaseCards || phaseCards.length === 0) { - return { - phaseCards: null, - currentPhase: null - }; - } - const sortedPhases = [...phaseCards].sort((left, right) => left.position - right.position); - const currentPhase = resolveCurrentRunPhaseCard(runMetadata, sortedPhases); - const resolveAutoAdvanceTarget = (): PhaseCard | null => { - if (!currentPhase) { - return ( - sortedPhases.find((phase) => - phaseHasAssignedExecutionWork(phase, steps) && canEnterConfiguredPhase(phase, sortedPhases, steps) - ) - ?? sortedPhases[0] - ?? null - ); - } - if (phaseHasNonTerminalExecutionWork(currentPhase, steps)) { - return null; - } - if (phaseHasPendingRequiredValidation(currentPhase, steps, sortedPhases)) { - if (normalizeValidationTier(currentPhase.validationGate.tier) === "dedicated") { - const currentIndex = sortedPhases.findIndex((phase) => phase.phaseKey === currentPhase.phaseKey); - for (let index = currentIndex + 1; index < sortedPhases.length; index += 1) { - const candidate = sortedPhases[index]!; - if (!isValidationPhase(candidate)) continue; - if (!phaseHasAssignedExecutionWork(candidate, steps)) continue; - if (canEnterConfiguredPhase(candidate, sortedPhases, steps)) { - return candidate; - } - } - } - return null; - } - const currentIndex = sortedPhases.findIndex((phase) => phase.phaseKey === currentPhase.phaseKey); - if (currentIndex < 0) return null; - for (let index = currentIndex + 1; index < sortedPhases.length; index += 1) { - const candidate = sortedPhases[index]!; - if (!phaseHasAssignedExecutionWork(candidate, steps)) continue; - if (canEnterConfiguredPhase(candidate, sortedPhases, steps)) { - return candidate; - } - } - return null; - }; - const targetPhase = resolveAutoAdvanceTarget(); - if (!targetPhase) { - return { - phaseCards: sortedPhases, - currentPhase - }; - } - if (currentPhase?.phaseKey === targetPhase.phaseKey) { - return { - phaseCards: sortedPhases, - currentPhase - }; - } - const applied = applyRunPhaseTransition({ - runId, - targetPhase, - reason: currentPhase ? "kernel_auto_advance" : "kernel_initialize_phase_runtime", - source: currentPhase ? "kernel_auto_phase_sync" : "kernel_phase_runtime_init" - }); - return { - phaseCards: sortedPhases, - currentPhase: applied?.currentPhase ?? targetPhase - }; - }; - - const refreshStepReadiness = (runId: string) => { - const rows = listStepRows(runId); - if (!rows.length) return; - const steps = rows.map(toStep); - const { phaseCards, currentPhase } = syncConfiguredPhaseRuntime(runId, steps); - const stepsById = new Map<string, OrchestratorStep>(steps.map((step) => [step.id, step] as const)); - const statusesById = new Map<string, OrchestratorStepStatus>(steps.map((step) => [step.id, step.status] as const)); - const now = nowIso(); - - for (const step of steps) { - const stepMeta = asRecord(step.metadata); - const isDisplayOnlyStep = isDisplayOnlyTaskStep(step) || stepMeta?.plannerLaunchTracker === true; - if (isDisplayOnlyStep) { - const nextDisplayStatus = TERMINAL_STEP_STATUSES.has(step.status) ? step.status : "pending"; - statusesById.set(step.id, nextDisplayStatus); - if (step.status !== nextDisplayStatus) { - db.run( - `update orchestrator_steps set status = ?, updated_at = ? where id = ? and run_id = ? and project_id = ?`, - [nextDisplayStatus, now, step.id, runId, projectId], - ); - } - continue; - } - if (step.status === "running" || TERMINAL_STEP_STATUSES.has(step.status)) continue; - const gate = evaluateDependencyGate(step, stepsById); - const phaseGate = evaluateConfiguredPhaseGate({ - step, - phaseCards, - currentPhase, - steps - }); - const validationGate = - gate.satisfied && phaseGate.satisfied - ? evaluateValidationDependencyGate({ - step, - stepsById, - phaseCards - }) - : { satisfied: true, blockingDependencyIds: [] }; - const stepPolicy = resolveStepPolicy(step); - const claimScoped = (stepPolicy.claimScopes ?? []).length > 0; - const nextRetryAtRaw = typeof step.metadata?.nextRetryAt === "string" ? step.metadata.nextRetryAt : null; - const nextRetryAtMs = nextRetryAtRaw ? Date.parse(nextRetryAtRaw) : NaN; - const retryDeferred = Number.isFinite(nextRetryAtMs) && nextRetryAtMs > Date.now(); - const stickyBlocked = step.status === "blocked" && step.metadata?.blockedSticky === true; - let next: OrchestratorStepStatus = step.status; - if (gate.satisfied && phaseGate.satisfied && validationGate.satisfied) { - if (stickyBlocked) { - next = "blocked"; - } else if (retryDeferred) { - next = "pending"; - } else if (step.status === "pending" || step.status === "blocked" || step.status === "ready") { - next = "ready"; - } - } else if (gate.permanentlyBlocked) { - next = "blocked"; - } else { - next = "pending"; - } - - if (next === "ready" && claimScoped && step.status === "blocked") { - // Claim conflicts can clear when claims expire/release. - const conflicts = (stepPolicy.claimScopes ?? []).some((scope) => { - const normalizedScopeValue = normalizeClaimScopeValue({ - scopeKind: scope.scopeKind, - scopeValue: scope.scopeValue - }); - if (!normalizedScopeValue) return true; - return Boolean( - findActiveClaimConflict({ - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue - }) - ); - }); - if (conflicts) next = "blocked"; - } - - if (next !== step.status) { - const nextMetadata = (() => { - if (!step.metadata || !("nextRetryAt" in step.metadata)) return step.metadata; - if (next !== "ready") return step.metadata; - const clone = { ...step.metadata }; - delete clone.nextRetryAt; - return clone; - })(); - db.run( - ` - update orchestrator_steps - set status = ?, - metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [next, JSON.stringify(nextMetadata ?? null), now, step.id, runId, projectId] - ); - stepsById.set(step.id, { - ...step, - status: next, - metadata: nextMetadata ?? null, - updatedAt: now - }); - statusesById.set(step.id, next); - emit({ type: "orchestrator-step-updated", runId, stepId: step.id, reason: "readiness_recomputed" }); - appendTimelineEvent({ - runId, - stepId: step.id, - eventType: "step_status_changed", - reason: "readiness_recomputed", - detail: { - from: step.status, - to: next, - joinPolicy: step.joinPolicy, - dependencies: step.dependencyStepIds, - dependencyGateSatisfied: gate.satisfied, - phaseGateSatisfied: phaseGate.satisfied, - phaseGateReason: phaseGate.reason, - validationGateSatisfied: validationGate.satisfied, - validationBlockingDependencyIds: validationGate.blockingDependencyIds - } - }); - } - } - }; - - /** - * Policy-aware run status evaluation. - * Resolves the execution policy from run metadata and delegates to - * `evaluateRunCompletion`. This NEVER returns terminal statuses — - * terminal transitions are exclusively via finalizeRun() or cancelRun(). - * When the evaluation signals completion readiness, the run transitions - * to "completing" and diagnostics are persisted for downstream consumers. - */ - const evaluateRunStatusWithPolicy = (runId: string): OrchestratorRunStatus => { - const steps = listStepRows(runId).map(toStep); - const runRow = getRunRow(runId); - const runMetadata = runRow ? parseJsonRecord(runRow.metadata_json) : null; - const rawPhases = resolveRunPhaseCardsFromMetadata(runMetadata); - const missionLevelSettings = resolveRunMissionLevelSettingsFromMetadata(runMetadata); - - const executionPolicy = runMetadata && isExecutionPolicyRecord(runMetadata.executionPolicy) - ? (runMetadata.executionPolicy as MissionExecutionPolicy) - : DEFAULT_EXECUTION_POLICY; - - const evaluation = - rawPhases && rawPhases.length > 0 && missionLevelSettings - ? evaluateRunCompletionFromPhases(steps, rawPhases, missionLevelSettings) - : evaluateRunCompletion(steps, executionPolicy); - - // When the evaluation signals completion readiness, persist diagnostics - // into run metadata so they are available via getRunGraph and other consumers. - if (evaluation.completionReady) { - const existingMetadata = runMetadata ?? {}; - const updatedMetadata = { - ...existingMetadata, - completionDiagnostics: evaluation.diagnostics, - completionRiskFactors: evaluation.riskFactors - }; - db.run( - ` - update orchestrator_runs - set metadata_json = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [JSON.stringify(updatedMetadata), nowIso(), runId, projectId] - ); - } - - // Map evaluation status: NEVER return terminal statuses from this function. - // Terminal transitions are exclusively via finalizeRun() or cancelRun(). - if (TERMINAL_RUN_STATUSES.has(evaluation.status) || evaluation.completionReady) { - return "completing"; - } - return evaluation.status; - }; - - const createContextSnapshotForAttempt = async (args: { - run: OrchestratorRun; - step: OrchestratorStep; - attemptId: string; - contextProfile: OrchestratorContextPolicyProfile; - }): Promise<CreateSnapshotResult> => { - const runtimeConfig = getRuntimeConfig(); - const existingCursor = (() => { - if (!args.run.metadata) return null; - const raw = args.run.metadata.runtimeCursor; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - return raw as Record<string, unknown>; - })(); - const previousPackDeltaSince = typeof existingCursor?.packDeltaSince === "string" ? existingCursor.packDeltaSince : null; - const stepType = (() => { - const fromMetadata = typeof args.step.metadata?.stepType === "string" ? args.step.metadata.stepType : null; - if (fromMetadata && fromMetadata.trim().length) return fromMetadata.trim(); - const missionStepKind = db.get<{ kind: string | null }>( - ` - select kind - from mission_steps - where id = ? - and mission_id = ? - and project_id = ? - limit 1 - `, - [args.step.missionStepId ?? "", args.run.missionId, projectId] - ); - return typeof missionStepKind?.kind === "string" && missionStepKind.kind.trim().length ? missionStepKind.kind.trim() : "manual"; - })(); - const laneExportLevel = args.contextProfile.laneExportLevel; - const projectExportLevel = stepType === "analysis" ? "standard" : args.contextProfile.projectExportLevel; - const lanePackKey = args.step.laneId ? `lane:${args.step.laneId}` : null; - const laneExport: { content: string; truncated: boolean } | null = null; - const projectExport: { content: string; truncated: boolean } = { content: "", truncated: false }; - - const docsPaths = readDocPaths(projectRoot); - let remainingBytes = args.contextProfile.maxDocBytes; - let docsConsumedBytes = 0; - let docsTruncatedCount = 0; - const docsRefs: OrchestratorDocsRef[] = []; - const fullDocs: Array<{ path: string; content: string; truncated: boolean }> = []; - - const docReadResults = await Promise.all( - docsPaths.map(async (abs) => { - try { - return { abs, buf: await fs.promises.readFile(abs) }; - } catch { - return null; - } - }) - ); - - for (const result of docReadResults) { - if (!result) continue; - const { abs, buf } = result; - const rel = path.relative(projectRoot, abs).replace(/\\/g, "/"); - const digest = sha256(buf); - const bytes = buf.length; - if (args.contextProfile.docsMode === "full_docs") { - const used = Math.min(Math.max(0, remainingBytes), bytes); - const chunk = buf.subarray(0, used).toString("utf8"); - const truncated = used < bytes; - docsConsumedBytes += used; - if (truncated) docsTruncatedCount += 1; - docsRefs.push({ - path: rel, - sha256: digest, - bytes, - truncated, - mode: "full_body" - }); - fullDocs.push({ - path: rel, - content: chunk, - truncated - }); - remainingBytes = Math.max(0, remainingBytes - used); - } else { - docsConsumedBytes += Math.min(64, bytes); // digest refs use only metadata bytes in prompt budget. - docsRefs.push({ - path: rel, - sha256: digest, - bytes, - truncated: false, - mode: "digest_ref" - }); - } - if (remainingBytes <= 0 && args.contextProfile.docsMode === "full_docs") break; - } - - const packDeltaDigest = null; - - const missionStepIds = new Set<string>(); - if (args.step.missionStepId) missionStepIds.add(args.step.missionStepId); - if (args.step.dependencyStepIds.length) { - const placeholders = args.step.dependencyStepIds.map(() => "?").join(", "); - const rows = db.all<{ mission_step_id: string | null }>( - ` - select mission_step_id - from orchestrator_steps - where project_id = ? - and run_id = ? - and id in (${placeholders}) - `, - [projectId, args.run.id, ...args.step.dependencyStepIds] - ); - for (const row of rows) { - if (row.mission_step_id) missionStepIds.add(row.mission_step_id); - } - } - const missionHandoffLimit = runtimeConfig.progressiveLoading ? 12 : 30; - const missionHandoffs = (() => { - const ids = [...missionStepIds]; - if (!ids.length) return [] as Array<{ id: string; handoff_type: string; created_at: string }>; - const placeholders = ids.map(() => "?").join(", "); - return db.all<{ id: string; handoff_type: string; created_at: string }>( - ` - select id, handoff_type, created_at - from mission_step_handoffs - where project_id = ? - and mission_id = ? - and mission_step_id in (${placeholders}) - order by created_at desc - limit 60 - `, - [projectId, args.run.missionId, ...ids] - ); - })(); - const missionHandoffIds = missionHandoffs.slice(0, missionHandoffLimit).map((row) => row.id); - const missionHandoffDigest = (() => { - if (missionHandoffs.length <= missionHandoffLimit) return null; - const summarized = missionHandoffs.slice(missionHandoffLimit); - const byType = summarized.reduce<Record<string, number>>((acc, row) => { - const key = String(row.handoff_type ?? "unknown").trim() || "unknown"; - acc[key] = (acc[key] ?? 0) + 1; - return acc; - }, {}); - return { - summarizedCount: summarized.length, - byType, - oldestCreatedAt: summarized[summarized.length - 1]?.created_at ?? null, - newestCreatedAt: summarized[0]?.created_at ?? null - }; - })(); - - const measureBytes = (value: unknown): number => { - try { - return Buffer.byteLength(JSON.stringify(value), "utf8"); - } catch { - return 0; - } - }; - - const runSteps = listStepRows(args.run.id).map(toStep); - const frontier = { - pending: 0, - ready: 0, - running: 0, - blocked: 0, - terminal: 0 - }; - for (const step of runSteps) { - if (TERMINAL_STEP_STATUSES.has(step.status)) { - frontier.terminal += 1; - } - if (step.status === "pending") { - frontier.pending += 1; - } else if (step.status === "ready") { - frontier.ready += 1; - } else if (step.status === "running") { - frontier.running += 1; - } else if (step.status === "blocked") { - frontier.blocked += 1; - } - } - const openQuestions = Number( - db.get<{ count: number }>( - ` - select count(*) as count - from mission_interventions - where project_id = ? - and mission_id = ? - and status = 'open' - and intervention_type = 'manual_input' - `, - [projectId, args.run.missionId] - )?.count ?? 0 - ); - const activeClaimsForRun = db.all<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where project_id = ? - and run_id = ? - and state = 'active' - `, - [projectId, args.run.id] - ); - const activeFileClaims = activeClaimsForRun.filter((claim) => claim.scope_kind === "file"); - let activeClaimConflicts = 0; - for (let i = 0; i < activeFileClaims.length; i += 1) { - for (let j = i + 1; j < activeFileClaims.length; j += 1) { - if (doFileClaimsOverlap(activeFileClaims[i]!.scope_value, activeFileClaims[j]!.scope_value)) { - activeClaimConflicts += 1; - } - } - } - const gateState = (() => { - const latest = db.get<{ report_json: string | null }>( - ` - select report_json - from orchestrator_gate_reports - where project_id = ? - order by generated_at desc - limit 1 - `, - [projectId] - ); - const report = parseJsonRecord(latest?.report_json ?? null); - const status = typeof report?.overallStatus === "string" ? report.overallStatus : "unknown"; - if (status === "pass" || status === "warn" || status === "fail") return status; - return "unknown"; - })(); - const recentDecisions = db.all<{ event_type: string; reason: string }>( - ` - select event_type, reason - from orchestrator_timeline_events - where project_id = ? - and run_id = ? - and event_type in ('attempt_retry_scheduled', 'attempt_completed', 'attempt_blocked', 'step_status_changed', 'run_status_changed', 'scheduler_tick') - order by created_at desc - limit 12 - `, - [projectId, args.run.id] - ).map((row) => `${row.event_type}:${row.reason}`); - - const laneStatusMap = runSteps - .sort((a, b) => a.stepIndex - b.stepIndex) - .map((step) => ({ - laneId: step.laneId, - stepKey: step.stepKey, - status: step.status - })); - - const CONTROL_PACK_V2_BUDGET_BYTES = 8_192; - const EXECUTION_PACK_V2_BUDGET_BYTES = 16_384; - const DEEP_PACK_V2_BUDGET_BYTES = 4_096; - - let controlPackV2: NonNullable<OrchestratorContextSnapshotCursor["controlPackV2"]> = { - budgetBytes: CONTROL_PACK_V2_BUDGET_BYTES, - consumedBytes: 0, - truncated: false, - frontier, - openQuestions, - activeClaims: activeClaimsForRun.length, - activeClaimConflicts, - gateState, - recentDecisions, - laneStatusMap - }; - controlPackV2.consumedBytes = measureBytes(controlPackV2); - if (controlPackV2.consumedBytes > CONTROL_PACK_V2_BUDGET_BYTES) { - controlPackV2 = { - ...controlPackV2, - truncated: true, - recentDecisions: controlPackV2.recentDecisions.slice(0, 8), - laneStatusMap: controlPackV2.laneStatusMap.slice(0, 16) - }; - controlPackV2.consumedBytes = measureBytes(controlPackV2); - } - if (controlPackV2.consumedBytes > CONTROL_PACK_V2_BUDGET_BYTES) { - controlPackV2 = { - ...controlPackV2, - recentDecisions: controlPackV2.recentDecisions.slice(0, 4), - laneStatusMap: controlPackV2.laneStatusMap.slice(0, 8) - }; - controlPackV2.consumedBytes = measureBytes(controlPackV2); - } - - const executionDependencies = args.step.dependencyStepIds - .map((depId) => runSteps.find((step) => step.id === depId)) - .filter((dep): dep is OrchestratorStep => Boolean(dep)) - .map((dep) => ({ stepId: dep.id, status: dep.status })); - let executionPackV2: NonNullable<OrchestratorContextSnapshotCursor["executionPackV2"]> = { - budgetBytes: EXECUTION_PACK_V2_BUDGET_BYTES, - consumedBytes: 0, - truncated: false, - stepKey: args.step.stepKey, - stepTitle: args.step.title, - dependencies: executionDependencies, - handoffIds: missionHandoffIds, - handoffDigest: missionHandoffDigest - }; - executionPackV2.consumedBytes = measureBytes(executionPackV2); - if (executionPackV2.consumedBytes > EXECUTION_PACK_V2_BUDGET_BYTES) { - executionPackV2 = { - ...executionPackV2, - truncated: true, - dependencies: executionPackV2.dependencies.slice(0, 12), - handoffIds: executionPackV2.handoffIds.slice(0, 10) - }; - executionPackV2.consumedBytes = measureBytes(executionPackV2); - } - - let deepPackV2: NonNullable<OrchestratorContextSnapshotCursor["deepPackV2"]> = { - budgetBytes: DEEP_PACK_V2_BUDGET_BYTES, - consumedBytes: 0, - truncated: false, - docsMode: args.contextProfile.docsMode === "full_docs" ? "full_body" : "digest_ref", - docsCount: docsRefs.length, - fullDocsIncluded: fullDocs.length, - docsRefsOnly: Math.max(0, docsRefs.length - fullDocs.length) - }; - deepPackV2.consumedBytes = measureBytes(deepPackV2); - if (deepPackV2.consumedBytes > DEEP_PACK_V2_BUDGET_BYTES) { - deepPackV2 = { - ...deepPackV2, - truncated: true - }; - deepPackV2.consumedBytes = measureBytes(deepPackV2); - } - - const laneVersionId = laneExport ? `live:${lanePackKey ?? "lane"}:${sha256(Buffer.from(laneExport.content))}` : null; - const projectVersionId = `live:project:${sha256(Buffer.from(projectExport.content))}`; - const cursor: OrchestratorContextSnapshotCursor = { - lanePackKey, - lanePackVersionId: laneVersionId, - lanePackVersionNumber: null, - projectPackKey: "project", - projectPackVersionId: projectVersionId, - projectPackVersionNumber: null, - packDeltaSince: previousPackDeltaSince, - docs: docsRefs, - packDeltaDigest, - missionHandoffIds, - missionHandoffDigest, - controlPackV2, - executionPackV2, - deepPackV2, - contextSources: [ - "control_pack_v2", - "execution_pack_v2", - "deep_pack_v2", - `context_export:project:${projectExportLevel}`, - ...(lanePackKey ? [`context_export:${lanePackKey}:${laneExportLevel}`] : []), - ...(packDeltaDigest ? ["delta_digest"] : []), - ...(missionHandoffIds.length ? ["mission_handoffs"] : []), - ...(missionHandoffDigest ? ["mission_handoff_digest"] : []), - `docs:${args.contextProfile.docsMode}` - ], - docsMode: args.contextProfile.docsMode === "full_docs" ? "full_body" : "digest_ref", - docsBudgetBytes: args.contextProfile.maxDocBytes, - docsConsumedBytes, - docsTruncatedCount - }; - - const contextPressure = - (cursor.docsConsumedBytes ?? 0) - / Math.max(1, cursor.docsBudgetBytes ?? args.contextProfile.maxDocBytes ?? 120_000); - if (contextPressure >= runtimeConfig.contextPressureThreshold) { - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attemptId, - eventType: "context_pressure_warning", - reason: "context_threshold_reached", - detail: { - pressure: contextPressure, - threshold: runtimeConfig.contextPressureThreshold, - docsConsumedBytes: cursor.docsConsumedBytes ?? 0, - docsBudgetBytes: cursor.docsBudgetBytes ?? 0, - handoffIds: missionHandoffIds.length, - summarizedHandoffs: missionHandoffDigest?.summarizedCount ?? 0 - } - }); - } - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attemptId, - eventType: "context_pack_v2_metrics", - reason: - controlPackV2.truncated || executionPackV2.truncated || deepPackV2.truncated - ? "pack_v2_truncated" - : "pack_v2_within_budget", - detail: { - control: { - consumedBytes: controlPackV2.consumedBytes, - budgetBytes: controlPackV2.budgetBytes, - truncated: controlPackV2.truncated - }, - execution: { - consumedBytes: executionPackV2.consumedBytes, - budgetBytes: executionPackV2.budgetBytes, - truncated: executionPackV2.truncated - }, - deep: { - consumedBytes: deepPackV2.consumedBytes, - budgetBytes: deepPackV2.budgetBytes, - truncated: deepPackV2.truncated - } - } - }); - - const snapshotId = randomUUID(); - const createdAt = nowIso(); - db.run( - ` - insert into orchestrator_context_snapshots( - id, - project_id, - run_id, - step_id, - attempt_id, - snapshot_type, - context_profile, - cursor_json, - created_at - ) values (?, ?, ?, ?, ?, 'attempt', ?, ?, ?) - `, - [ - snapshotId, - projectId, - args.run.id, - args.step.id, - args.attemptId, - args.contextProfile.id, - JSON.stringify(cursor), - createdAt - ] - ); - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attemptId, - eventType: "context_snapshot_created", - reason: "attempt_context_resolved", - detail: { - snapshotId, - docsMode: cursor.docsMode, - docsCount: docsRefs.length, - docsTruncatedCount, - stepType, - hasDeltaDigest: Boolean(packDeltaDigest), - handoffCount: missionHandoffIds.length - } - }); - - const fullRuntimeCursor: OrchestratorContextSnapshotCursor = { - ...cursor, - packDeltaSince: createdAt - }; - const runtimeCursorPayload = { - runtimeCursor: compactRuntimeCursorForRunMetadata(fullRuntimeCursor) - }; - const currentMetadata = args.run.metadata ?? {}; - db.run( - ` - update orchestrator_runs - set runtime_cursor_json = ?, - metadata_json = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [ - JSON.stringify(fullRuntimeCursor), - JSON.stringify({ - ...currentMetadata, - ...runtimeCursorPayload - }), - createdAt, - args.run.id, - projectId - ] - ); - - return { - snapshotId, - cursor, - laneExport, - projectExport, - docsRefs, - fullDocs - }; - }; - - const tryRunConflictResolverChain = async (args: { - run: OrchestratorRun; - step: OrchestratorStep; - attempt: OrchestratorAttempt; - }): Promise< - | { - status: "succeeded" | "blocked" | "failed"; - result?: OrchestratorAttemptResultEnvelope; - errorClass?: OrchestratorErrorClass; - errorMessage?: string; - metadata?: Record<string, unknown> | null; - } - | null - > => { - const metadata = args.step.metadata ?? {}; - const integrationConfig = - metadata.integration && typeof metadata.integration === "object" && !Array.isArray(metadata.integration) - ? (metadata.integration as Record<string, unknown>) - : metadata; - const isMergeFlowStep = - integrationConfig.integrationFlow === true - || integrationConfig.requiresConflictResolver === true - || integrationConfig.stepType === "merge"; - if (!isMergeFlowStep) return null; - - const targetLaneId = - typeof integrationConfig.targetLaneId === "string" && integrationConfig.targetLaneId.trim().length - ? integrationConfig.targetLaneId.trim() - : ""; - const sourceLaneIds = Array.isArray(integrationConfig.sourceLaneIds) - ? integrationConfig.sourceLaneIds.map((value) => String(value ?? "").trim()).filter(Boolean) - : []; - if (!targetLaneId || sourceLaneIds.length === 0) { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Integration step is missing targetLaneId/sourceLaneIds metadata.", - metadata: { - integrationConfigInvalid: true - } - }; - } - if (!conflictService) { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Conflict service is unavailable for integration step execution." - }; - } - - const externalProvider: ExternalConflictResolverProvider = - integrationConfig.externalProvider === "claude" ? "claude" : "codex"; - const scenario: PrepareResolverSessionArgs["scenario"] = - sourceLaneIds.length > 1 - ? "integration-merge" - : (integrationConfig.scenario as PrepareResolverSessionArgs["scenario"] | undefined) ?? "single-merge"; - const integrationLaneName = - typeof integrationConfig.integrationLaneName === "string" ? integrationConfig.integrationLaneName : undefined; - const allowSubscriptionFallback = integrationConfig.allowSubscriptionFallback === true; - - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_started", - reason: "external_cli_first", - detail: { - targetLaneId, - sourceLaneIds, - externalProvider, - scenario, - allowSubscriptionFallback - } - }); - - const prepared = await conflictService.prepareResolverSession({ - provider: externalProvider, - targetLaneId, - sourceLaneIds, - integrationLaneName, - scenario - }); - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "external_cli_prepare_completed", - detail: { - status: prepared.status, - runId: prepared.runId, - integrationLaneId: prepared.integrationLaneId, - contextGaps: prepared.contextGaps - } - }); - - if (prepared.status === "ready") { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "External resolver session prepared. Operator action required to run CLI resolver.", - result: normalizeEnvelope({ - success: false, - summary: "External CLI resolver is prepared and awaiting operator execution.", - outputs: { - resolverRunId: prepared.runId, - promptFilePath: prepared.promptFilePath, - cwdWorktreePath: prepared.cwdWorktreePath, - cwdLaneId: prepared.cwdLaneId, - integrationLaneId: prepared.integrationLaneId - }, - warnings: prepared.warnings, - trackedSession: true - }), - metadata: { - integrationStage: "external_cli_ready", - externalProvider, - resolverRunId: prepared.runId - } - }; - } - - if (allowSubscriptionFallback && sourceLaneIds.length === 1) { - try { - const preview: ConflictProposalPreview = await conflictService.prepareProposal({ - laneId: sourceLaneIds[0]!, - peerLaneId: targetLaneId - }); - const proposal: ConflictProposal = await conflictService.requestProposal({ - laneId: sourceLaneIds[0]!, - peerLaneId: targetLaneId, - contextDigest: preview.contextDigest - }); - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "subscription_fallback_completed", - detail: { - proposalId: proposal.id, - source: proposal.source - } - }); - return { - status: "succeeded", - result: normalizeEnvelope({ - success: true, - summary: "Subscription fallback generated a deterministic conflict proposal.", - outputs: { - proposalId: proposal.id, - confidence: proposal.confidence, - source: proposal.source - }, - warnings: [], - trackedSession: true - }), - metadata: { - integrationStage: "subscription_fallback", - proposalId: proposal.id, - proposalSource: proposal.source - } - }; - } catch (error) { - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "subscription_fallback_failed", - detail: { - error: error instanceof Error ? error.message : String(error) - } - }); - } - } - - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "manual_intervention_required", - detail: { - targetLaneId, - sourceLaneIds - } - }); - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Integration resolver chain reached intervention stage.", - metadata: { - integrationStage: "intervention_required", - targetLaneId, - sourceLaneIds - } - }; - }; - - const tryRunMergePrAutomation = async (args: { - run: OrchestratorRun; - step: OrchestratorStep; - attempt: OrchestratorAttempt; - }): Promise< - | { - status: "succeeded" | "blocked" | "failed"; - result?: OrchestratorAttemptResultEnvelope; - errorClass?: OrchestratorErrorClass; - errorMessage?: string; - metadata?: Record<string, unknown> | null; - } - | null - > => { - const metadata = args.step.metadata ?? {}; - const stepType = typeof metadata.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - if (stepType !== "merge") return null; - if (metadata.prAutomationDisabled === true) return null; - if (!prService) return null; - - const mergeModeRaw = typeof metadata.mergeMode === "string" ? metadata.mergeMode.trim() : "manual"; - const mergeMode = mergeModeRaw === "auto_if_green" ? "auto_if_green" : "manual"; - const mergeMethodRaw = typeof metadata.mergeMethod === "string" ? metadata.mergeMethod.trim() : "squash"; - const mergeMethod: "merge" | "squash" | "rebase" = - mergeMethodRaw === "merge" || mergeMethodRaw === "squash" || mergeMethodRaw === "rebase" - ? mergeMethodRaw - : "squash"; - - const targetLaneId = - typeof metadata.targetLaneId === "string" && metadata.targetLaneId.trim().length - ? metadata.targetLaneId.trim() - : args.step.laneId ?? null; - const sourceLaneIds = ( - Array.isArray(metadata.sourceLaneIds) - ? metadata.sourceLaneIds.map((entry) => String(entry ?? "").trim()).filter(Boolean) - : args.step.laneId - ? [args.step.laneId] - : [] - ).filter((entry, index, all) => all.indexOf(entry) === index); - if (!sourceLaneIds.length) { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Merge step could not resolve a source lane for PR automation.", - metadata: { - mergeMode, - missingSourceLane: true - } - }; - } - - const getLaneBranch = (laneId: string | null): string | null => { - if (!laneId) return null; - const lane = db.get<{ branch_ref: string | null }>( - ` - select branch_ref - from lanes - where id = ? - and project_id = ? - limit 1 - `, - [laneId, projectId] - ); - const branch = typeof lane?.branch_ref === "string" ? branchNameFromRef(lane.branch_ref) : ""; - return branch.length > 0 ? branch : null; - }; - - const persistStepMergeMetadata = (patch: Record<string, unknown>): void => { - const row = getStepRow(args.step.id); - if (!row) return; - const next = { - ...(parseJsonRecord(row.metadata_json) ?? {}), - ...patch - }; - db.run( - ` - update orchestrator_steps - set metadata_json = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [JSON.stringify(next), nowIso(), args.step.id, projectId] - ); - }; - - const prTitleBase = - typeof metadata.prTitle === "string" && metadata.prTitle.trim().length - ? metadata.prTitle.trim() - : args.step.title.trim().length - ? args.step.title.trim() - : "Orchestrator merge"; - const prBodyBase = - typeof metadata.prBody === "string" && metadata.prBody.trim().length - ? metadata.prBody.trim() - : [ - "Automated merge step generated by ADE orchestrator.", - "", - `Run: ${args.run.id}`, - `Step: ${args.step.stepKey}` - ].join("\n"); - - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_started", - reason: "merge_pr_automation_started", - detail: { - mergeMode, - sourceLaneIds, - targetLaneId - } - }); - - try { - let prSummary: ReturnType<NonNullable<typeof prService>["getForLane"]> | null = null; - let integrationLaneId: string | null = null; - - if (sourceLaneIds.length === 1) { - const laneId = sourceLaneIds[0]!; - const existing = prService.getForLane(laneId); - const fresh = - existing - ? (await prService.refresh({ prId: existing.id }).then((rows) => rows[0] ?? existing).catch(() => existing)) - : null; - if (fresh && (fresh.state === "open" || fresh.state === "draft")) { - prSummary = fresh; - } else if (fresh && fresh.state === "merged") { - prSummary = fresh; - } else { - const created = await prService.createFromLane({ - laneId, - title: prTitleBase, - body: prBodyBase, - draft: false, - ...(getLaneBranch(targetLaneId) ? { baseBranch: getLaneBranch(targetLaneId)! } : {}) - }); - prSummary = created; - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "merge_pr_created", - detail: { - prId: created.id, - laneId - } - }); - } - } else { - const priorIntegrationLaneId = - typeof metadata.integrationLaneId === "string" && metadata.integrationLaneId.trim().length - ? metadata.integrationLaneId.trim() - : null; - if (priorIntegrationLaneId) { - const existing = prService.getForLane(priorIntegrationLaneId); - if (existing) { - prSummary = await prService.refresh({ prId: existing.id }).then((rows) => rows[0] ?? existing).catch(() => existing); - integrationLaneId = priorIntegrationLaneId; - } - } - if (!prSummary) { - const baseBranch = - getLaneBranch(targetLaneId) - ?? getLaneBranch(sourceLaneIds[0]!) - ?? ""; - if (!baseBranch.length) { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Merge PR automation could not resolve a base branch for integration PR.", - metadata: { - mergeMode, - sourceLaneIds, - targetLaneId - } - }; - } - const integrationLaneName = - typeof metadata.integrationLaneName === "string" && metadata.integrationLaneName.trim().length - ? metadata.integrationLaneName.trim() - : `orchestrator-${args.run.id.slice(0, 8)}-${args.step.stepKey.slice(0, 24)}`; - const createPrArgs = { - sourceLaneIds, - integrationLaneName, - baseBranch, - title: prTitleBase, - body: prBodyBase, - draft: false - }; - let created: Awaited<ReturnType<typeof prService.createIntegrationPr>>; - try { - created = await prService.createIntegrationPr(createPrArgs); - } catch (firstError) { - // Retry once after a short delay - await new Promise((resolve) => setTimeout(resolve, 2_000)); - try { - created = await prService.createIntegrationPr(createPrArgs); - } catch (retryError) { - // Fallback: attempt to create as a draft PR - try { - created = await prService.createIntegrationPr({ ...createPrArgs, draft: true }); - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "integration_pr_created_as_draft_fallback", - detail: { - originalError: firstError instanceof Error ? firstError.message : String(firstError), - retryError: retryError instanceof Error ? retryError.message : String(retryError), - sourceLaneIds - } - }); - } catch (draftError) { - // Both retry and draft fallback failed — request user intervention - const errorDetail = { - originalError: firstError instanceof Error ? firstError.message : String(firstError), - retryError: retryError instanceof Error ? retryError.message : String(retryError), - draftError: draftError instanceof Error ? draftError.message : String(draftError) - }; - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "intervention_opened", - reason: "integration_pr_creation_failed", - detail: { - ...errorDetail, - sourceLaneIds, - integrationLaneName, - baseBranch, - message: "Integration PR creation failed after retry and draft fallback. Manual intervention required." - } - }); - return { - status: "blocked", - errorClass: "transient", - errorMessage: `Integration PR creation failed after retry and draft fallback. Please create the PR manually. Errors: ${JSON.stringify(errorDetail)}`, - metadata: { - mergeMode, - sourceLaneIds, - targetLaneId, - requiresUserIntervention: true - } - }; - } - } - } - prSummary = created.pr; - integrationLaneId = created.integrationLaneId; - persistStepMergeMetadata({ - integrationLaneId, - integrationPrId: created.pr.id - }); - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "integration_pr_created", - detail: { - prId: created.pr.id, - integrationLaneId, - sourceLaneIds - } - }); - } - } - - if (!prSummary) { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Merge PR automation did not produce a pull request.", - metadata: { - mergeMode, - sourceLaneIds - } - }; - } - - if (prSummary.state === "merged") { - return { - status: "succeeded", - result: normalizeEnvelope({ - success: true, - summary: "Merge PR is already merged.", - outputs: { - prId: prSummary.id, - prNumber: prSummary.githubPrNumber, - prUrl: prSummary.githubUrl, - integrationLaneId - }, - warnings: [], - trackedSession: true - }), - metadata: { - mergeStage: "already_merged", - prId: prSummary.id - } - }; - } - - if (mergeMode === "manual") { - return { - status: "blocked", - errorClass: "policy", - errorMessage: "Merge PR is ready and awaiting operator approval.", - result: normalizeEnvelope({ - success: false, - summary: "Merge PR prepared. Operator approval required before landing.", - outputs: { - prId: prSummary.id, - prNumber: prSummary.githubPrNumber, - prUrl: prSummary.githubUrl, - mergeMode, - integrationLaneId - }, - warnings: [], - trackedSession: true - }), - metadata: { - mergeStage: "awaiting_approval", - prId: prSummary.id, - integrationLaneId - } - }; - } - - const status = await prService.getStatus(prSummary.id); - const mergeReasons: string[] = []; - if (status.state !== "open" && status.state !== "draft") mergeReasons.push(`state=${status.state}`); - if (status.checksStatus === "failing" || status.checksStatus === "pending") mergeReasons.push(`checks=${status.checksStatus}`); - if (status.reviewStatus === "changes_requested") mergeReasons.push(`review=${status.reviewStatus}`); - if (status.mergeConflicts) mergeReasons.push("merge_conflicts=true"); - if (status.behindBaseBy > 0) mergeReasons.push(`behind_base_by=${status.behindBaseBy}`); - if (!status.isMergeable) mergeReasons.push("is_mergeable=false"); - if (mergeReasons.length > 0) { - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "merge_pr_not_green", - detail: { - prId: prSummary.id, - reasons: mergeReasons - } - }); - return { - status: "blocked", - errorClass: "policy", - errorMessage: `Merge PR is not green for auto-merge (${mergeReasons.join(", ")}).`, - result: normalizeEnvelope({ - success: false, - summary: "Merge PR created but not merge-ready yet.", - outputs: { - prId: prSummary.id, - prNumber: prSummary.githubPrNumber, - prUrl: prSummary.githubUrl, - checksStatus: status.checksStatus, - reviewStatus: status.reviewStatus, - mergeConflicts: status.mergeConflicts, - behindBaseBy: status.behindBaseBy, - isMergeable: status.isMergeable, - integrationLaneId - }, - warnings: mergeReasons, - trackedSession: true - }), - metadata: { - mergeStage: "waiting_green", - prId: prSummary.id - } - }; - } - - const landed = await prService.land({ - prId: prSummary.id, - method: mergeMethod - }); - if (!landed.success) { - return { - status: "failed", - errorClass: "executor_failure", - errorMessage: landed.error ?? "Merge API returned failure.", - metadata: { - mergeStage: "land_failed", - prId: prSummary.id, - mergeMethod - } - }; - } - - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "integration_chain_stage", - reason: "merge_pr_landed", - detail: { - prId: prSummary.id, - mergeCommitSha: landed.mergeCommitSha, - mergeMethod - } - }); - return { - status: "succeeded", - result: normalizeEnvelope({ - success: true, - summary: "Merge PR landed successfully.", - outputs: { - prId: prSummary.id, - prNumber: prSummary.githubPrNumber, - prUrl: prSummary.githubUrl, - mergeCommitSha: landed.mergeCommitSha, - branchDeleted: landed.branchDeleted, - laneArchived: landed.laneArchived, - integrationLaneId - }, - warnings: [], - trackedSession: true - }), - metadata: { - mergeStage: "landed", - prId: prSummary.id, - mergeMethod - } - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendTimelineEvent({ - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - eventType: "intervention_opened", - reason: "merge_pr_automation_failed", - detail: { - error: message, - message: "Merge PR automation failed. User intervention is required to resolve this step." - } - }); - return { - status: "blocked", - errorClass: "transient", - errorMessage: `Merge PR automation failed: ${message}. User intervention required.`, - metadata: { - requiresUserIntervention: true - } - }; - } - }; - - const defaultAdapterFor = (kind: OrchestratorExecutorKind): OrchestratorExecutorAdapter | null => { - // The generic adapter below only knows how to launch claude/codex CLIs. - // Droid uses its own adapter registered elsewhere — falling back here would - // silently spawn a Claude worker for a Droid step. - if (!["claude", "codex", "cursor", "opencode"].includes(kind)) return null; - return { - kind, - requiresLaneId: true, - start: async (args) => { - if (!args.step.laneId) { - return { - status: "failed", - errorClass: "policy", - errorMessage: "Executor scaffolds require step.laneId to create tracked sessions." - }; - } - const title = `[orchestrator:${kind}] ${args.step.title}`; - try { - const contextDir = path.join(resolveAdeLayout(projectRoot).orchestratorContextsDir, args.run.id); - fs.mkdirSync(contextDir, { recursive: true }); - const contextFilePath = path.join(contextDir, `${args.attempt.id}.json`); - const contextManifest = { - schema: "ade.orchestratorWorkerContext.v1", - mission: { - missionId: args.run.missionId, - runId: args.run.id, - stepId: args.step.id, - attemptId: args.attempt.id, - stepKey: args.step.stepKey, - title: args.step.title, - joinPolicy: args.step.joinPolicy, - dependencyStepIds: args.step.dependencyStepIds - }, - contextProfile: args.contextProfile.id, - packs: { - lane: args.laneExport - ? { - packKey: args.step.laneId ? `lane:${args.step.laneId}` : null, - level: args.contextProfile.laneExportLevel, - approxTokens: null, - contentPreview: clipText(args.laneExport.content, 3_000) - } - : null, - project: { - packKey: "project", - level: args.contextProfile.projectExportLevel, - approxTokens: null, - contentPreview: clipText(args.projectExport.content, 2_000) - } - }, - docs: args.docsRefs.slice(0, 24), - fullDocsPreview: args.fullDocs.slice(0, 3).map((entry) => ({ - path: entry.path, - truncated: entry.truncated, - contentPreview: clipText(entry.content, 1_200) - })), - generatedAt: nowIso() - }; - fs.writeFileSync(contextFilePath, `${JSON.stringify(contextManifest, null, 2)}\n`, "utf8"); - - const requiresPlanApproval = - args.step.metadata?.requiresPlanApproval === true || args.step.metadata?.coordinationPattern === "plan_then_implement"; - const readOnlyExecution = args.step.metadata?.readOnlyExecution === true || requiresPlanApproval; - const promptParts = [ - `You are an ADE mission worker for step "${args.step.title}".`, - requiresPlanApproval - ? "Work in planning mode only. Do not mutate files. Return a concise implementation plan and risk notes." - : "Implement the step with focused, minimal edits and run the relevant validation commands.", - `Load full context from: ${contextFilePath}`, - "Keep output concise and structured for orchestrator ingestion." - ]; - - // Inject recovery context for retry attempts in default adapter - if (args.previousCheckpoint || args.previousAttemptSummary) { - const recoveryLines: string[] = ["RECOVERY CONTEXT — PREVIOUS PROGRESS:", "Your previous attempt on this step was interrupted."]; - if (args.previousCheckpoint) { - recoveryLines.push("Checkpoint from before interruption:", "---", args.previousCheckpoint, "---"); - } - if (args.previousAttemptSummary) { - recoveryLines.push("Previous attempt outcome:", args.previousAttemptSummary); - } - recoveryLines.push("Resume from where you left off. Do not redo completed work. Verify file state before continuing."); - promptParts.push(recoveryLines.join("\n")); - } - - const prompt = promptParts.join("\n"); - - const modelRef = typeof args.step.metadata?.modelId === "string" ? args.step.metadata.modelId.trim() : ""; - if (!modelRef.length) { - return { - status: "failed", - errorClass: "policy", - errorMessage: `Step '${args.step.stepKey}' is missing required metadata.modelId.`, - metadata: { - adapterKind: kind, - adapterState: "model_id_missing" - } - }; - } - const descriptor = resolveModelDescriptor(modelRef); - if (!descriptor) { - return { - status: "failed", - errorClass: "policy", - errorMessage: `Model '${modelRef}' is not registered.`, - metadata: { - adapterKind: kind, - adapterState: "model_not_registered", - modelId: modelRef - } - }; - } - const cliCommand = descriptor?.cliCommand === "codex" ? "codex" : "claude"; - const commandArgs: string[] = []; - const commandPreviewParts: string[] = [cliCommand]; - const model = modelRef; - if (model) { - const effectiveModel = cliCommand === "claude" - ? resolveClaudeCliModel(model) - : cliCommand === "codex" - ? resolveCodexCliModel(model) - : model; - commandArgs.push("--model", effectiveModel); - commandPreviewParts.push("--model", shellEscapeArg(effectiveModel)); - } - const cliMode = args.permissionConfig?.cli?.mode ?? "full-auto"; - if (cliCommand === "codex") { - const codexProviderMode = args.permissionConfig?._providers?.codex; - const mappedCodex = codexProviderMode === "config-toml" ? null : mapPermissionToCodex(codexProviderMode); - if (!readOnlyExecution && codexProviderMode === "config-toml") { - // Let Codex read its own repository/user config without forcing flags. - } else { - const sandboxMode = readOnlyExecution || cliMode === "read-only" - ? "read-only" - : mappedCodex?.sandbox ?? args.permissionConfig?.cli?.sandboxPermissions ?? "workspace-write"; - const approvalPolicy = readOnlyExecution || cliMode === "read-only" ? "on-request" : mappedCodex?.approvalPolicy ?? "untrusted"; - commandArgs.push( - "--sandbox", - sandboxMode, - "--ask-for-approval", - approvalPolicy, - ); - commandPreviewParts.push( - "--sandbox", - shellEscapeArg(sandboxMode), - "--ask-for-approval", - shellEscapeArg(approvalPolicy), - ); - } - } else { - if (!readOnlyExecution && cliMode === "full-auto") { - commandArgs.push("--dangerously-skip-permissions"); - commandPreviewParts.push("--dangerously-skip-permissions"); - } else { - const claudePermissionMode = readOnlyExecution || cliMode === "read-only" ? "plan" : "acceptEdits"; - commandArgs.push("--permission-mode", claudePermissionMode); - commandPreviewParts.push("--permission-mode", shellEscapeArg(claudePermissionMode)); - } - } - // ADE_CLI_AGENT_GUIDANCE is injected into the worker's system prompt - // via buildFullPrompt in baseOrchestratorAdapter when hasMissionTooling - // is true. Do not prepend it again here — that would duplicate the - // "## ADE CLI" block for Claude workers. - const promptFilePath = writeWorkerPromptFile({ - projectRoot, - attemptId: args.attempt.id, - prompt, - }); - - let launchCommand; - if (cliCommand === "codex") { - const resolvedCodex = resolveCodexExecutable(); - commandArgs.push("exec", "-"); - commandPreviewParts.push("exec", "-"); - const startupCommand = `exec ${commandPreviewParts.join(" ")} < ${shellEscapeArg(promptFilePath)}`; - const launchFilePath = writeWorkerLaunchFile({ - projectRoot, - attemptId: args.attempt.id, - command: resolvedCodex.path, - commandArgs, - promptFilePath, - }); - launchCommand = nodeWorkerLaunch({ - startupCommand, - launchFilePath, - }); - } else { - const resolvedClaude = resolveClaudeCodeExecutable(); - commandArgs.push("-p"); - commandPreviewParts.push("-p"); - const startupCommand = `exec ${commandPreviewParts.join(" ")} < ${shellEscapeArg(promptFilePath)}`; - const launchFilePath = writeWorkerLaunchFile({ - projectRoot, - attemptId: args.attempt.id, - command: resolvedClaude.path, - commandArgs, - promptFilePath, - }); - launchCommand = nodeWorkerLaunch({ - startupCommand, - launchFilePath, - }); - } - - const session = await args.createTrackedSession({ - laneId: args.step.laneId, - cols: 120, - rows: 36, - title, - toolType: `${kind}-orchestrated` as TerminalToolType, - command: launchCommand.command, - args: launchCommand.args, - env: launchCommand.env, - startupCommand: launchCommand.startupCommand, - }); - return { - status: "accepted", - sessionId: session.sessionId, - metadata: { - adapterKind: kind, - adapterState: "worker_spawned", - contextFilePath, - contextDigest: sha256(JSON.stringify(contextManifest)), - planMode: readOnlyExecution, - startupCommandPreview: launchCommand.startupCommand.slice(0, 320), - localFirst: true - } - }; - } catch (error) { - return { - status: "failed", - errorClass: "executor_failure", - errorMessage: error instanceof Error ? error.message : String(error), - metadata: { - adapterKind: kind, - adapterState: "scaffold_start_failed" - } - }; - } - } - }; - }; - - return { - getContextProfile(): OrchestratorContextPolicyProfile { - return DEFAULT_CONTEXT_POLICY; - }, - - registerExecutorAdapter(adapter: OrchestratorExecutorAdapter) { - adapters.set(adapter.kind, adapter); - }, - - unregisterExecutorAdapter(kind: OrchestratorExecutorKind) { - adapters.delete(kind); - }, - - async createOrchestratedSession( - args: Omit<PtyCreateArgs, "tracked"> & { tracked?: boolean } - ): Promise<{ ptyId: string; sessionId: string }> { - if (!ptyService) throw new Error("PTY service unavailable for orchestrator execution."); - if (args.tracked === false) { - throw new Error("Orchestrated execution requires tracked=true sessions."); - } - return ptyService.create({ - ...args, - tracked: true - }); - }, - - listRuns(args: { status?: OrchestratorRunStatus; missionId?: string; limit?: number } = {}): OrchestratorRun[] { - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (args.status) { - where.push("status = ?"); - params.push(args.status); - } - if (args.missionId) { - where.push("mission_id = ?"); - params.push(args.missionId); - } - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 100))) : 100; - const rows = db.all<RunRow>( - ` - select - id, - mission_id, - project_id, - status, - context_profile, - scheduler_state, - runtime_cursor_json, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - from orchestrator_runs - where ${where.join(" and ")} - order by created_at desc - limit ? - `, - [...params, limit] - ); - return rows.map(toRun); - }, - - listSteps(runId: string): OrchestratorStep[] { - return listStepRows(runId).map(toStep); - }, - - listAttempts(args: { runId?: string; limit?: number } = {}): OrchestratorAttempt[] { - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (args.runId) { - where.push("run_id = ?"); - params.push(args.runId); - } - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))) : 200; - const rows = db.all<AttemptRow>( - ` - select - id, - run_id, - step_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - from orchestrator_attempts - where ${where.join(" and ")} - order by created_at desc - limit ? - `, - [...params, limit] - ); - return rows.map(toAttempt); - }, - - listClaims(args: { runId?: string; state?: OrchestratorClaimState; limit?: number } = {}): OrchestratorClaim[] { - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (args.runId) { - where.push("run_id = ?"); - params.push(args.runId); - } - if (args.state) { - where.push("state = ?"); - params.push(args.state); - } - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))) : 200; - const rows = db.all<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where ${where.join(" and ")} - order by acquired_at desc - limit ? - `, - [...params, limit] - ); - return rows.map(toClaim); - }, - - listContextSnapshots(args: { runId?: string; limit?: number } = {}): OrchestratorContextSnapshot[] { - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (args.runId) { - where.push("run_id = ?"); - params.push(args.runId); - } - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))) : 200; - const rows = db.all<ContextSnapshotRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - snapshot_type, - context_profile, - cursor_json, - created_at - from orchestrator_context_snapshots - where ${where.join(" and ")} - order by created_at desc - limit ? - `, - [...params, limit] - ); - return rows.map(toContextSnapshot); - }, - - listHandoffs(args: { missionId?: string; runId?: string; limit?: number } = {}): MissionStepHandoff[] { - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (args.missionId) { - where.push("mission_id = ?"); - params.push(args.missionId); - } - if (args.runId) { - where.push("run_id = ?"); - params.push(args.runId); - } - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))) : 200; - const rows = db.all<HandoffRow>( - ` - select - id, - mission_id, - mission_step_id, - run_id, - step_id, - attempt_id, - handoff_type, - producer, - payload_json, - created_at - from mission_step_handoffs - where ${where.join(" and ")} - order by created_at desc - limit ? - `, - [...params, limit] - ); - return rows.map(toHandoff); - }, - - createHandoff(args: { - missionId: string; - missionStepId?: string | null; - runId?: string | null; - stepId?: string | null; - attemptId?: string | null; - handoffType: string; - producer: string; - payload: Record<string, unknown>; - }): MissionStepHandoff { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId) throw new Error("missionId is required."); - const handoffType = String(args.handoffType ?? "").trim(); - if (!handoffType) throw new Error("handoffType is required."); - const producer = String(args.producer ?? "").trim(); - if (!producer) throw new Error("producer is required."); - return insertHandoff({ - missionId, - missionStepId: args.missionStepId ?? null, - runId: args.runId ?? null, - stepId: args.stepId ?? null, - attemptId: args.attemptId ?? null, - handoffType, - producer, - payload: args.payload ?? {} - }); - }, - - listTimeline(args: { runId: string; limit?: number }): OrchestratorTimelineEvent[] { - return listTimelineRows(args).map(toTimelineEvent); - }, - - appendTimelineEvent(args: { - runId: string; - stepId?: string | null; - attemptId?: string | null; - claimId?: string | null; - eventType: string; - reason: string; - detail?: Record<string, unknown> | null; - }): OrchestratorTimelineEvent { - return appendTimelineEvent(args); - }, - - appendRuntimeEvent(args: { - runId: string; - stepId?: string | null; - attemptId?: string | null; - sessionId?: string | null; - eventType: OrchestratorRuntimeEventType; - eventKey?: string | null; - occurredAt?: string | null; - payload?: Record<string, unknown> | null; - }): OrchestratorRuntimeBusEvent { - return persistRuntimeEvent(args); - }, - - emitRuntimeUpdate(args: { runId: string; stepId?: string | null; reason: string }): void { - emit({ - type: "orchestrator-step-updated", - runId: args.runId, - ...(args.stepId ? { stepId: args.stepId } : {}), - reason: args.reason - }); - }, - - listRuntimeEvents(args: { - runId?: string; - attemptId?: string; - sessionId?: string; - eventTypes?: OrchestratorRuntimeEventType[]; - since?: string | null; - limit?: number; - } = {}): OrchestratorRuntimeBusEvent[] { - return listRuntimeEventRows(args).map(toRuntimeEvent); - }, - - addReflection(input: ReflectionAddInput): OrchestratorReflectionEntry { - const missionId = String(input.missionId ?? "").trim(); - const runId = String(input.runId ?? "").trim(); - const stepId = String(input.stepId ?? "").trim() || null; - const attemptId = String(input.attemptId ?? "").trim() || null; - const agentRole = String(input.agentRole ?? "").trim(); - const phase = String(input.phase ?? "").trim(); - const observation = String(input.observation ?? "").trim(); - const recommendation = String(input.recommendation ?? "").trim(); - const context = String(input.context ?? "").trim(); - const signalType = normalizeReflectionSignalType(String(input.signalType ?? "").trim()); - const occurredAtRaw = String(input.occurredAt ?? "").trim(); - - if (!missionId) { - throw new ReflectionValidationError("mission_id_required", "missionId is required."); - } - if (!runId) { - throw new ReflectionValidationError("run_id_required", "runId is required."); - } - if (!agentRole) { - throw new ReflectionValidationError("agent_role_required", "agentRole is required."); - } - if (!phase) { - throw new ReflectionValidationError("phase_required", "phase is required."); - } - if (!signalType) { - throw new ReflectionValidationError("signal_type_invalid", "signalType must be one of wish|frustration|idea|pattern|limitation."); - } - if (!observation) { - throw new ReflectionValidationError("observation_required", "observation is required."); - } - if (!recommendation) { - throw new ReflectionValidationError("recommendation_required", "recommendation is required."); - } - if (!context) { - throw new ReflectionValidationError("context_required", "context is required."); - } - if (!occurredAtRaw) { - throw new ReflectionValidationError("occurred_at_invalid", "occurredAt is required and must be a valid ISO-8601 timestamp."); - } - if (!isStrictIsoTimestamp(occurredAtRaw)) { - throw new ReflectionValidationError("occurred_at_invalid", "occurredAt is required and must be a valid ISO-8601 timestamp.", { occurredAt: occurredAtRaw }); - } - const occurredAtMs = Date.parse(occurredAtRaw); - const occurredAt = new Date(occurredAtMs).toISOString(); - - const runRow = getRunRow(runId); - if (!runRow) { - throw new ReflectionValidationError("run_not_found", `Run not found: ${runId}`, { runId }); - } - if (String(runRow.mission_id) !== missionId) { - throw new ReflectionValidationError("run_mission_mismatch", "runId does not belong to missionId.", { runId, missionId, actualMissionId: runRow.mission_id }); - } - if (stepId) { - const stepRow = getStepRow(stepId); - if (!stepRow) { - throw new ReflectionValidationError("step_not_found", `Step not found: ${stepId}`, { stepId }); - } - if (String(stepRow.run_id) !== runId) { - throw new ReflectionValidationError("step_run_mismatch", "stepId does not belong to runId.", { stepId, runId, actualRunId: stepRow.run_id }); - } - } - if (attemptId) { - const attemptRow = getAttemptRow(attemptId); - if (!attemptRow) { - throw new ReflectionValidationError("attempt_not_found", `Attempt not found: ${attemptId}`, { attemptId }); - } - if (String(attemptRow.run_id) !== runId) { - throw new ReflectionValidationError("attempt_run_mismatch", "attemptId does not belong to runId.", { attemptId, runId, actualRunId: attemptRow.run_id }); - } - if (stepId && String(attemptRow.step_id) !== stepId) { - throw new ReflectionValidationError("attempt_step_mismatch", "attemptId does not belong to stepId.", { attemptId, stepId, actualStepId: attemptRow.step_id }); - } - } - - const id = randomUUID(); - const createdAt = nowIso(); - const entry: OrchestratorReflectionEntry = { - id, - projectId, - missionId, - runId, - stepId, - attemptId, - agentRole, - phase, - signalType, - observation, - recommendation, - context, - occurredAt, - createdAt, - schemaVersion: 1, - }; - - db.run("begin immediate"); - try { - db.run( - ` - insert into orchestrator_reflections( - id, project_id, mission_id, run_id, step_id, attempt_id, - agent_role, phase, signal_type, observation, recommendation, - context, occurred_at, created_at, schema_version - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) - `, - [id, projectId, missionId, runId, stepId, attemptId, agentRole, phase, signalType, observation, recommendation, context, occurredAt, createdAt] - ); - appendReflectionLedgerEntry(projectRoot, missionId, entry as unknown as Record<string, unknown>); - db.run("commit"); - } catch (error) { - try { - db.run("rollback"); - } catch { - // best effort rollback - } - throw error; - } - - persistRuntimeEvent({ runId, stepId, attemptId, eventType: "reflection_added", payload: { reflectionId: id, signalType, phase, agentRole } }); - return entry; - }, - - listReflections(args: { runId?: string; missionId?: string; limit?: number } = {}): OrchestratorReflectionEntry[] { - const runId = String(args.runId ?? "").trim(); - const missionId = String(args.missionId ?? "").trim(); - const limit = Math.max(1, Math.min(500, Math.floor(Number(args.limit ?? 200) || 200))); - const where: string[] = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (runId) { - where.push("run_id = ?"); - params.push(runId); - } - if (missionId) { - where.push("mission_id = ?"); - params.push(missionId); - } - params.push(limit); - const rows = db.all<any>(`select * from orchestrator_reflections where ${where.join(" and ")} order by occurred_at desc, created_at desc limit ?`, params); - return rows.map((row) => ({ - id: String(row.id), - projectId: String(row.project_id), - missionId: String(row.mission_id), - runId: String(row.run_id), - stepId: row.step_id ? String(row.step_id) : null, - attemptId: row.attempt_id ? String(row.attempt_id) : null, - agentRole: String(row.agent_role), - phase: String(row.phase), - signalType: row.signal_type as OrchestratorReflectionEntry["signalType"], - observation: String(row.observation), - recommendation: String(row.recommendation ?? ""), - context: String(row.context ?? ""), - occurredAt: String(row.occurred_at), - createdAt: String(row.created_at), - schemaVersion: 1, - })); - }, - - listRetrospectives(args: { missionId?: string; limit?: number } = {}): MissionRetrospective[] { - const missionId = String(args.missionId ?? "").trim(); - const limit = Math.max(1, Math.min(100, Math.floor(Number(args.limit ?? 20) || 20))); - const where = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (missionId) { - where.push("mission_id = ?"); - params.push(missionId); - } - params.push(limit); - const rows = db.all<{ payload_json: string }>( - `select payload_json from orchestrator_retrospectives where ${where.join(" and ")} order by generated_at desc limit ?`, - params - ); - const out: MissionRetrospective[] = []; - for (const row of rows) { - const parsed = parseRetrospectivePayload(row.payload_json); - if (parsed) out.push(parsed); - } - return out; - }, - - listRetrospectiveTrends(args: { missionId?: string; runId?: string; limit?: number } = {}): OrchestratorRetrospectiveTrend[] { - const missionId = String(args.missionId ?? "").trim(); - const runId = String(args.runId ?? "").trim(); - const limit = Math.max(1, Math.min(500, Math.floor(Number(args.limit ?? 100) || 100))); - const where = ["project_id = ?"]; - const params: SqlValue[] = [projectId]; - if (missionId) { - where.push("mission_id = ?"); - params.push(missionId); - } - if (runId) { - where.push("run_id = ?"); - params.push(runId); - } - params.push(limit); - const rows = db.all<any>( - `select * from orchestrator_retrospective_trends where ${where.join(" and ")} order by created_at desc limit ?`, - params - ); - return rows.map((row) => ({ - id: String(row.id), - projectId: String(row.project_id), - missionId: String(row.mission_id), - runId: String(row.run_id), - retrospectiveId: String(row.retrospective_id), - sourceMissionId: String(row.source_mission_id), - sourceRunId: String(row.source_run_id), - sourceRetrospectiveId: String(row.source_retrospective_id), - painPointKey: String(row.pain_point_key), - painPointLabel: String(row.pain_point_label), - status: row.status as OrchestratorRetrospectiveTrend["status"], - previousPainScore: Number(row.previous_pain_score ?? 0), - currentPainScore: Number(row.current_pain_score ?? 0), - createdAt: String(row.created_at), - })); - }, - - listRetrospectivePatternStats(args: { limit?: number } = {}): OrchestratorRetrospectivePatternStat[] { - const limit = Math.max(1, Math.min(500, Math.floor(Number(args.limit ?? 100) || 100))); - const rows = db.all<any>( - ` - select * - from orchestrator_reflection_pattern_stats - where project_id = ? - order by occurrence_count desc, updated_at desc - limit ? - `, - [projectId, limit] - ); - return rows.map((row) => ({ - id: String(row.id), - projectId: String(row.project_id), - patternKey: String(row.pattern_key), - patternLabel: String(row.pattern_label), - occurrenceCount: Number(row.occurrence_count ?? 0), - firstSeenRetrospectiveId: String(row.first_seen_retrospective_id), - firstSeenRunId: String(row.first_seen_run_id), - lastSeenRetrospectiveId: String(row.last_seen_retrospective_id), - lastSeenRunId: String(row.last_seen_run_id), - createdAt: String(row.created_at), - updatedAt: String(row.updated_at), - })); - }, - - generateRunRetrospective(args: { runId: string }): MissionRetrospective | null { - const runId = String(args.runId ?? "").trim(); - if (!runId) return null; - const runRow = getRunRow(runId); - if (!runRow) return null; - const run = toRun(runRow); - if (!TERMINAL_RUN_STATUSES.has(run.status)) return null; - const existingRow = db.get<{ payload_json: string }>( - ` - select payload_json - from orchestrator_retrospectives - where project_id = ? - and run_id = ? - limit 1 - `, - [projectId, runId] - ); - const existing = parseRetrospectivePayload(existingRow?.payload_json); - if (existing) return existing; - - const graph = this.getRunGraph({ runId, timelineLimit: 500 }); - const reflections = this.listReflections({ runId, limit: 500 }); - const missionStateDocCounts = loadMissionStateDocCounts(projectRoot, runId); - const generatedAt = nowIso(); - - const painPointSignals = reflections - .filter((entry) => entry.signalType === "frustration" || entry.signalType === "limitation") - .map((entry) => entry.observation) - .filter(Boolean); - const improvementSignals = reflections - .filter((entry) => entry.signalType === "idea" || entry.signalType === "wish") - .map((entry) => (entry.recommendation || entry.observation).trim()) - .filter(Boolean); - const patternSignals = reflections - .filter((entry) => entry.signalType === "pattern") - .map((entry) => entry.observation) - .filter(Boolean); - - const painPointCounter = buildPainPointCounter(painPointSignals); - const topPainPoints = [...painPointCounter.entries()] - .sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label)) - .slice(0, 12) - .map((entry) => entry[1].label); - const topImprovements = [...new Set(improvementSignals.map((item) => item.trim()).filter(Boolean))] - .slice(0, 12); - const patternsToCapture = [...new Set(patternSignals.map((item) => item.trim()).filter(Boolean))] - .slice(0, 12); - - const previousRows = db.all<{ id: string; mission_id: string; run_id: string; payload_json: string }>( - ` - select id, mission_id, run_id, payload_json - from orchestrator_retrospectives - where project_id = ? - and mission_id != ? - and run_id != ? - order by generated_at desc - limit ? - `, - [projectId, run.missionId, runId, RETROSPECTIVE_TREND_LOOKBACK_LIMIT] - ); - const previousPainScores = new Map<string, { label: string; score: number; sourceRetrospectiveId: string; sourceMissionId: string; sourceRunId: string }>(); - const trendEntries: Array<{ - sourceMissionId: string; - sourceRunId: string; - sourceRetrospectiveId: string; - painPointKey: string; - painPointLabel: string; - status: "resolved" | "still_open" | "worsened"; - previousPainScore: number; - currentPainScore: number; - }> = []; - for (const row of previousRows) { - const previousRetrospective = parseRetrospectivePayload(row.payload_json); - if (!previousRetrospective) continue; - const counter = buildPainPointCounter(previousRetrospective.topPainPoints ?? []); - for (const [key, value] of counter.entries()) { - const currentPainScore = painPointCounter.get(key)?.count ?? 0; - trendEntries.push({ - sourceMissionId: String(row.mission_id), - sourceRunId: String(row.run_id), - sourceRetrospectiveId: String(row.id), - painPointKey: key, - painPointLabel: value.label, - status: computePainTrendStatus(value.count, currentPainScore), - previousPainScore: value.count, - currentPainScore, - }); - const existingScore = previousPainScores.get(key); - if (!existingScore || value.count > existingScore.score) { - previousPainScores.set(key, { - label: value.label, - score: value.count, - sourceRetrospectiveId: String(row.id), - sourceMissionId: String(row.mission_id), - sourceRunId: String(row.run_id), - }); - } - } - } - const changelog = [...previousPainScores.entries()] - .sort((a, b) => b[1].score - a[1].score || a[1].label.localeCompare(b[1].label)) - .slice(0, 12) - .map(([key, prev]) => { - const currentPainScore = painPointCounter.get(key)?.count ?? 0; - const status = computePainTrendStatus(prev.score, currentPainScore); - const currentState = status === "resolved" - ? "Pain point did not recur in latest run." - : status === "worsened" - ? "Pain point frequency increased in latest run." - : "Pain point persisted in latest run."; - return { - previousPainPoint: prev.label, - status, - currentState, - sourceRetrospectiveId: prev.sourceRetrospectiveId, - sourceMissionId: prev.sourceMissionId, - sourceRunId: prev.sourceRunId, - previousPainScore: prev.score, - currentPainScore, - }; - }); - const runtimeEventCount = graph.runtimeEvents?.length ?? 0; - - const retrospective: MissionRetrospective = { - id: `retro:${runId}`, - missionId: run.missionId, - runId, - generatedAt, - schemaVersion: 1, - finalStatus: run.status, - wins: graph.steps.filter((step) => step.status === "succeeded").slice(0, 12).map((step) => `Completed ${step.stepKey}: ${step.title}`), - failures: graph.steps.filter((step) => step.status === "failed").slice(0, 12).map((step) => `Failed ${step.stepKey}: ${step.title}`), - unresolvedRisks: graph.steps.filter((step) => step.status === "blocked").slice(0, 12).map((step) => `Blocked ${step.stepKey}: ${step.title}`), - followUpActions: topImprovements.slice(0, 12), - topPainPoints, - topImprovements, - patternsToCapture, - estimatedImpact: topImprovements.length - ? `Signals: timeline=${graph.timeline.length}, runtimeEvents=${runtimeEventCount}, reflections=${reflections.length}, decisions=${missionStateDocCounts.decisions}, activeIssues=${missionStateDocCounts.activeIssues}. Addressing top improvements should reduce retries and unresolved blockers in subsequent missions.` - : `Signals: timeline=${graph.timeline.length}, runtimeEvents=${runtimeEventCount}, reflections=${reflections.length}, decisions=${missionStateDocCounts.decisions}, activeIssues=${missionStateDocCounts.activeIssues}. No clear improvements captured from reflections in this run.`, - changelog, - }; - - db.run( - ` - insert or ignore into orchestrator_retrospectives( - id, project_id, mission_id, run_id, generated_at, final_status, payload_json, schema_version, created_at - ) values (?, ?, ?, ?, ?, ?, ?, 1, ?) - `, - [retrospective.id, projectId, retrospective.missionId, retrospective.runId, retrospective.generatedAt, retrospective.finalStatus, JSON.stringify(retrospective), generatedAt] - ); - const persistedRow = db.get<{ payload_json: string }>( - ` - select payload_json - from orchestrator_retrospectives - where project_id = ? - and run_id = ? - limit 1 - `, - [projectId, runId] - ); - const persisted = parseRetrospectivePayload(persistedRow?.payload_json); - if (!persisted) return null; - - persistRetrospectiveArtifact(projectRoot, persisted.missionId, persisted.runId, persisted as unknown as Record<string, unknown>); - - db.run( - `delete from orchestrator_retrospective_trends where project_id = ? and retrospective_id = ?`, - [projectId, persisted.id] - ); - for (const entry of trendEntries) { - const trendId = randomUUID(); - db.run( - ` - insert into orchestrator_retrospective_trends( - id, project_id, mission_id, run_id, retrospective_id, - source_mission_id, source_run_id, source_retrospective_id, - pain_point_key, pain_point_label, status, previous_pain_score, current_pain_score, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - trendId, - projectId, - persisted.missionId, - persisted.runId, - persisted.id, - entry.sourceMissionId, - entry.sourceRunId, - entry.sourceRetrospectiveId, - entry.painPointKey, - entry.painPointLabel, - entry.status, - entry.previousPainScore, - entry.currentPainScore, - generatedAt - ] - ); - } - - const seenPatternKeys = new Set<string>(); - for (const patternLabel of persisted.patternsToCapture) { - const normalizedLabel = String(patternLabel ?? "").trim(); - const patternKey = normalizePainPointKey(normalizedLabel); - if (!patternKey || seenPatternKeys.has(patternKey)) continue; - seenPatternKeys.add(patternKey); - const existingPattern = db.get<any>( - ` - select * - from orchestrator_reflection_pattern_stats - where project_id = ? - and pattern_key = ? - limit 1 - `, - [projectId, patternKey] - ); - let patternStatId: string; - const nextCount = existingPattern ? Number(existingPattern.occurrence_count ?? 0) + 1 : 1; - if (existingPattern) { - patternStatId = String(existingPattern.id); - db.run( - ` - update orchestrator_reflection_pattern_stats - set - pattern_label = ?, - occurrence_count = ?, - last_seen_retrospective_id = ?, - last_seen_run_id = ?, - updated_at = ? - where id = ? - `, - [normalizedLabel, nextCount, persisted.id, persisted.runId, generatedAt, patternStatId] - ); - } else { - patternStatId = randomUUID(); - db.run( - ` - insert into orchestrator_reflection_pattern_stats( - id, project_id, pattern_key, pattern_label, - occurrence_count, first_seen_retrospective_id, first_seen_run_id, - last_seen_retrospective_id, last_seen_run_id, - created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [patternStatId, projectId, patternKey, normalizedLabel, 1, persisted.id, persisted.runId, persisted.id, persisted.runId, generatedAt, generatedAt] - ); - } - db.run( - ` - insert or ignore into orchestrator_reflection_pattern_sources( - id, project_id, pattern_stat_id, retrospective_id, mission_id, run_id, created_at - ) values (?, ?, ?, ?, ?, ?, ?) - `, - [randomUUID(), projectId, patternStatId, persisted.id, persisted.missionId, persisted.runId, generatedAt] - ); - } - - persistRuntimeEvent({ runId, eventType: "retrospective_generated", payload: { retrospectiveId: persisted.id } }); - return persisted; - }, - - getRunGraph(args: { runId: string; timelineLimit?: number }): OrchestratorRunGraph { - const runRow = getRunRow(args.runId); - if (!runRow) throw new Error(`Run not found: ${args.runId}`); - const run = toRun(runRow); - const steps = listStepRows(args.runId).map(toStep); - const runMetadata = run.metadata ?? {}; - const graphPhases = resolveRunPhaseCardsFromMetadata(runMetadata); - const graphSettings = resolveRunMissionLevelSettingsFromMetadata(runMetadata); - - let completion: RunCompletionEvaluation; - if (graphPhases && graphPhases.length > 0 && graphSettings) { - completion = evaluateRunCompletionFromPhases(steps, graphPhases, graphSettings); - } else { - const executionPolicy = isExecutionPolicyRecord(runMetadata.executionPolicy) - ? (runMetadata.executionPolicy as MissionExecutionPolicy) - : DEFAULT_EXECUTION_POLICY; - completion = evaluateRunCompletion(steps, executionPolicy); - } - return { - run, - steps, - attempts: listAttemptRows(args.runId).map(toAttempt), - claims: this.listClaims({ runId: args.runId, limit: 1_000 }), - contextSnapshots: this.listContextSnapshots({ runId: args.runId, limit: 1_000 }), - handoffs: this.listHandoffs({ runId: args.runId, limit: 1_000 }), - timeline: this.listTimeline({ runId: args.runId, limit: args.timelineLimit ?? 300 }), - runtimeEvents: this.listRuntimeEvents({ runId: args.runId, limit: 1_000 }), - completionEvaluation: completion - }; - }, - - startRunFromMission(args: { - missionId: string; - runId?: string; - contextProfile?: OrchestratorContextProfileId; - schedulerState?: string; - metadata?: Record<string, unknown> | null; - runMode?: "autopilot" | "manual"; - autopilotOwnerId?: string; - defaultExecutorKind?: OrchestratorExecutorKind; - defaultRetryLimit?: number; - }): { run: OrchestratorRun; steps: OrchestratorStep[] } { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId) throw new Error("missionId is required."); - const mission = db.get<{ id: string; prompt: string | null; lane_id: string | null; metadata_json: string | null }>( - ` - select id, prompt, lane_id, metadata_json - from missions - where id = ? - and project_id = ? - limit 1 - `, - [missionId, projectId] - ); - if (!mission?.id) throw new Error(`Mission not found: ${missionId}`); - const runtimeConfig = getRuntimeConfig(); - const missionSteps = db.all<{ - id: string; - step_index: number; - title: string; - detail: string | null; - kind: string; - lane_id: string | null; - metadata_json: string | null; - }>( - ` - select id, step_index, title, detail, kind, lane_id, metadata_json - from mission_steps - where mission_id = ? - and project_id = ? - order by step_index asc, created_at asc - `, - [missionId, projectId] - ); - const requestedRunMode = args.runMode === "manual" ? "manual" : "autopilot"; - const requestedExecutor = normalizeExecutorKind(String(args.defaultExecutorKind ?? "opencode")); - const fallbackExecutor = requestedRunMode === "manual" ? "manual" : requestedExecutor === "manual" ? "opencode" : requestedExecutor; - const autopilotEnabled = requestedRunMode === "autopilot" && fallbackExecutor !== "manual"; - const autopilotOwnerId = String(args.autopilotOwnerId ?? "").trim() || "orchestrator-autopilot"; - const missionMetadata = parseJsonRecord(mission.metadata_json) ?? {}; - const missionPhaseConfiguration = asRecord(missionMetadata.phaseConfiguration); - const missionLevelSettings = asRecord(missionMetadata.missionLevelSettings); - const missionExecutionPolicy = isExecutionPolicyRecord(missionMetadata.executionPolicy) - ? (missionMetadata.executionPolicy as MissionExecutionPolicy) - : null; - const missionPhaseOverride = Array.isArray(missionMetadata.phaseOverride) - ? missionMetadata.phaseOverride as PhaseCard[] - : null; - const missionPhaseProfileId = - typeof missionMetadata.phaseProfileId === "string" && missionMetadata.phaseProfileId.trim().length > 0 - ? missionMetadata.phaseProfileId.trim() - : null; - const plannerSummary = asRecord(asRecord(missionMetadata.plannerPlan)?.missionSummary); - const plannerParallelismRaw = Number( - args.metadata?.plannerParallelismCap ?? plannerSummary?.parallelismCap ?? Number.NaN - ); - const plannerParallelismCap = - Number.isFinite(plannerParallelismRaw) && plannerParallelismRaw > 0 ? Math.floor(plannerParallelismRaw) : null; - const autopilotParallelismCap = Math.max( - 1, - Math.min(runtimeConfig.maxParallelWorkers, plannerParallelismCap ?? runtimeConfig.maxParallelWorkers) - ); - - const descriptors = missionSteps.map((row, index) => { - const metadata = parseJsonRecord(row.metadata_json) ?? {}; - const stepIndex = Number.isFinite(Number(row.step_index)) ? Number(row.step_index) : index; - const explicitKey = typeof metadata.stepKey === "string" ? metadata.stepKey.trim() : ""; - const stepKey = explicitKey.length ? explicitKey : `mission_step_${stepIndex}_${index}`; - return { - row, - index, - metadata, - stepIndex, - stepKey - }; - }); - - const stepKeysByIndex = new Map<number, string[]>(); - for (const descriptor of descriptors) { - const bucket = stepKeysByIndex.get(descriptor.stepIndex) ?? []; - bucket.push(descriptor.stepKey); - stepKeysByIndex.set(descriptor.stepIndex, bucket); - } - const descriptorByStepKey = new Map(descriptors.map((descriptor) => [descriptor.stepKey, descriptor] as const)); - - const resolveDependencyKeys = (descriptor: (typeof descriptors)[number]): string[] => { - const hasExplicitDependencies = - Array.isArray(descriptor.metadata.dependencyStepKeys) - || Array.isArray(descriptor.metadata.dependencyIndices); - const explicitKeys = Array.isArray(descriptor.metadata.dependencyStepKeys) - ? descriptor.metadata.dependencyStepKeys - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : []; - const indexedKeys = parseNumericDependencyIndices(descriptor.metadata).flatMap((depIndex) => stepKeysByIndex.get(depIndex) ?? []); - const joined = [...explicitKeys, ...indexedKeys]; - const deduped = [...new Set(joined.filter((key) => key !== descriptor.stepKey))]; - if (deduped.length) return deduped; - if (hasExplicitDependencies) return []; - if (descriptor.index > 0) { - return [descriptors[descriptor.index - 1]!.stepKey]; - } - return []; - }; - - const normalized: StartOrchestratorRunStepInput[] = descriptors.map((descriptor) => { - const { row, metadata } = descriptor; - const explicitExecutor = - typeof metadata.executorKind === "string" ? normalizeExecutorKind(metadata.executorKind) : fallbackExecutor; - const retryLimitRaw = Number(metadata.retryLimit); - const configuredRetryLimit = Number(args.defaultRetryLimit ?? runtimeConfig.maxRetriesPerStep); - const retryLimit = Number.isFinite(retryLimitRaw) - ? Math.max(0, Math.floor(retryLimitRaw)) - : Math.max(0, Math.floor(configuredRetryLimit)); - const joinPolicy = - typeof metadata.joinPolicy === "string" ? normalizeJoinPolicy(String(metadata.joinPolicy)) : "all_success"; - const quorumRaw = Number(metadata.quorumCount); - const quorumCount = Number.isFinite(quorumRaw) && quorumRaw > 0 ? Math.floor(quorumRaw) : undefined; - const dependencyStepKeys = resolveDependencyKeys(descriptor); - const stepType = String(metadata.stepType ?? row.kind ?? "").trim() || "manual"; - const integrationHints = (() => { - if (stepType !== "integration" && stepType !== "merge") { - return { - targetLaneId: null as string | null, - sourceLaneIds: [] as string[] - }; - } - const existingTarget = typeof metadata.targetLaneId === "string" ? metadata.targetLaneId.trim() : ""; - const targetLaneId = - existingTarget.length > 0 - ? existingTarget - : typeof row.lane_id === "string" && row.lane_id.trim().length > 0 - ? row.lane_id.trim() - : typeof mission.lane_id === "string" && mission.lane_id.trim().length > 0 - ? mission.lane_id.trim() - : null; - const explicitSourceLaneIds = Array.isArray(metadata.sourceLaneIds) - ? metadata.sourceLaneIds - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : []; - if (explicitSourceLaneIds.length > 0) { - return { - targetLaneId, - sourceLaneIds: [...new Set(explicitSourceLaneIds.filter((entry) => entry !== targetLaneId))] - }; - } - const derivedSourceLaneIds = dependencyStepKeys - .map((depKey) => { - const depDescriptor = descriptorByStepKey.get(depKey); - const depLaneId = depDescriptor?.row?.lane_id; - return typeof depLaneId === "string" ? depLaneId.trim() : ""; - }) - .filter((laneId) => laneId.length > 0 && laneId !== targetLaneId); - return { - targetLaneId, - sourceLaneIds: [...new Set(derivedSourceLaneIds)] - }; - })(); - const inferredPattern = - joinPolicy === "any_success" - ? "speculative_parallel" - : dependencyStepKeys.length > 1 - ? "fan_in_merge" - : stepType === "analysis" - ? "plan_then_implement" - : stepType === "review" - ? "review_and_revise" - : dependencyStepKeys.length === 0 && requestedRunMode === "autopilot" - ? "parallel_fan_out" - : "sequential_chain"; - const stepInstructions = - typeof metadata.instructions === "string" && metadata.instructions.trim().length - ? metadata.instructions.trim() - : typeof row.detail === "string" && row.detail.trim().length - ? row.detail.trim() - : ""; - const explicitRequiresPlanApproval = - typeof metadata.requiresPlanApproval === "boolean" ? metadata.requiresPlanApproval : null; - const inferredRequiresPlanApproval = - inferredPattern === "plan_then_implement" || stepType === "analysis"; - const normalizedExecutor = normalizeExecutorKind(String(explicitExecutor ?? "manual")); - const isAiTeammate = ["claude", "codex", "cursor", "droid", "opencode"].includes(normalizedExecutor); - const requiresPlanApproval = explicitRequiresPlanApproval != null - ? explicitRequiresPlanApproval - : runtimeConfig.teammatePlanMode === "required" && isAiTeammate - ? true - : runtimeConfig.teammatePlanMode === "off" - ? false - : inferredRequiresPlanApproval; - const mergedMetadata: Record<string, unknown> = { - ...metadata, - instructions: stepInstructions, - stepType, - requiresPlanApproval, - teammatePlanMode: runtimeConfig.teammatePlanMode, - coordinationPattern: metadata.coordinationPattern ?? inferredPattern - }; - if ( - (stepType === "integration" || stepType === "merge") - && integrationHints.targetLaneId - && typeof mergedMetadata.targetLaneId !== "string" - ) { - mergedMetadata.targetLaneId = integrationHints.targetLaneId; - } - if ( - (stepType === "integration" || stepType === "merge") - && integrationHints.sourceLaneIds.length > 0 - && !Array.isArray(mergedMetadata.sourceLaneIds) - ) { - mergedMetadata.sourceLaneIds = integrationHints.sourceLaneIds; - } - return { - missionStepId: row.id, - stepKey: descriptor.stepKey, - title: row.title, - stepIndex: descriptor.stepIndex, - laneId: row.lane_id, - dependencyStepKeys, - joinPolicy, - quorumCount, - retryLimit, - executorKind: explicitExecutor, - policy: parseStepPolicyFromMetadata(metadata), - metadata: mergedMetadata - }; - }); - - if (!normalized.length) { - normalized.push({ - stepKey: "mission_step_0_0", - title: "Execute mission objective", - stepIndex: 0, - laneId: null, - retryLimit: Math.max(0, Math.floor(args.defaultRetryLimit ?? runtimeConfig.maxRetriesPerStep)), - executorKind: fallbackExecutor, - metadata: { - stepType: "manual", - missionPrompt: mission.prompt ?? "", - requiresPlanApproval: false, - coordinationPattern: "sequential_chain" - } - }); - } - - const plannerMetadata = descriptors - .map((descriptor) => descriptor.metadata?.planner) - .find((planner) => planner && typeof planner === "object" && !Array.isArray(planner)) as Record<string, unknown> | undefined; - - const coordinationPatterns = normalized.reduce<Record<string, number>>((acc, stepInput) => { - const key = - stepInput.metadata && typeof stepInput.metadata.coordinationPattern === "string" - ? stepInput.metadata.coordinationPattern - : "sequential_chain"; - acc[key] = (acc[key] ?? 0) + 1; - return acc; - }, {}); - const launchMeta = asRecord(missionMetadata.launch); - const launchTeamRuntime = asRecord(launchMeta?.teamRuntime); - const launchAgentRuntime = asRecord(launchMeta?.agentRuntime); - - const started = this.startRun({ - missionId, - runId: args.runId, - contextProfile: args.contextProfile, - schedulerState: args.schedulerState, - metadata: { - ...(args.metadata ?? {}), - missionGoal: mission.prompt ?? "", - missionPrompt: mission.prompt ?? "", - runMode: requestedRunMode, - ...(missionLevelSettings ? { missionLevelSettings } : {}), - ...(missionPhaseConfiguration ? { phaseConfiguration: missionPhaseConfiguration } : {}), - ...(missionExecutionPolicy ? { executionPolicy: missionExecutionPolicy } : {}), - ...(missionPhaseOverride ? { phaseOverride: missionPhaseOverride } : {}), - ...(missionPhaseProfileId ? { phaseProfileId: missionPhaseProfileId } : {}), - ...(launchTeamRuntime - ? { - teamRuntime: { - ...launchTeamRuntime, - ...normalizeAgentRuntimeFlags(launchTeamRuntime) - } - } - : {}), - ...(launchAgentRuntime - ? { - agentRuntime: normalizeAgentRuntimeFlags(launchAgentRuntime) - } - : {}), - planner: { - source: "mission_steps", - stepCount: normalized.length, - strategy: typeof plannerMetadata?.strategy === "string" ? plannerMetadata.strategy : null, - version: typeof plannerMetadata?.version === "string" ? plannerMetadata.version : null, - parallelismCap: autopilotParallelismCap - }, - coordination: { - patterns: coordinationPatterns - }, - orchestratorConfig: { - teammatePlanMode: runtimeConfig.teammatePlanMode, - maxParallelWorkers: runtimeConfig.maxParallelWorkers, - contextPressureThreshold: runtimeConfig.contextPressureThreshold, - progressiveLoading: runtimeConfig.progressiveLoading, - fileReservationGuardMode: runtimeConfig.fileReservationGuardMode - }, - autopilot: { - enabled: autopilotEnabled, - executorKind: autopilotEnabled ? fallbackExecutor : "manual", - ownerId: autopilotOwnerId, - parallelismCap: autopilotParallelismCap - } - }, - steps: normalized - }); - - if (autopilotEnabled) { - void this - .startReadyAutopilotAttempts({ - runId: started.run.id, - reason: "run_started" - }) - .catch(() => {}); - } - - return started; - }, - - async startReadyAutopilotAttempts(args: { runId: string; reason?: string }): Promise<number> { - const runId = String(args.runId ?? "").trim(); - if (!runId.length) return 0; - - // Early guard: skip if the run is paused or missing (before lock acquisition) - const preCheckRow = getRunRow(runId); - if (!preCheckRow) { - return 0; - } - const preCheckRun = toRun(preCheckRow); - const preCheckGate = getRunInterventionGateState(preCheckRun); - if (preCheckRun.status === "paused" || preCheckGate.blocked) { - return 0; - } - - if (autopilotRunLocks.has(runId)) { - if (args.reason === "initial_ramp_up") { - let waited = 0; - while (autopilotRunLocks.has(runId) && waited < 2000) { - await new Promise((r) => setTimeout(r, 100)); - waited += 100; - } - if (autopilotRunLocks.has(runId)) return 0; - } else { - return 0; - } - } - - autopilotRunLocks.add(runId); - try { - const runRow = getRunRow(runId); - if (!runRow) return 0; - const run = toRun(runRow); - const runGate = getRunInterventionGateState(run); - if (!runCanStartAttempts(run.status) || runGate.blocked) return 0; - - const autopilot = parseAutopilotConfig(run.metadata, DEFAULT_ORCHESTRATOR_RUNTIME_CONFIG.maxParallelWorkers); - if (!autopilot.enabled) return 0; - const parallelismCap = Math.max(1, Math.min(32, autopilot.parallelismCap)); - const computeEffectiveParallelismCap = (): { - cap: number; - reasons: string[]; - } => { - const latestRunRow = getRunRow(runId); - const latestRunMetadata = latestRunRow ? parseJsonRecord(latestRunRow.metadata_json) : null; - const aiDecisions = asRecord(latestRunMetadata?.aiDecisions); - const aiParallelismRaw = Number(aiDecisions?.parallelismCap ?? Number.NaN); - const aiParallelismCap = Number.isFinite(aiParallelismRaw) - ? Math.max(1, Math.min(32, Math.floor(aiParallelismRaw))) - : null; - if (aiParallelismCap != null) return { cap: aiParallelismCap, reasons: ["ai_decision_cap"] }; - return { cap: parallelismCap, reasons: ["configured_cap"] }; - }; - let startedAttempts = 0; - const startedByExecutor: Record<string, number> = {}; - let loops = 0; - while (loops < 12) { - loops += 1; - const loopRunRow = getRunRow(runId); - if (!loopRunRow) break; - const loopRun = toRun(loopRunRow); - const loopGate = getRunInterventionGateState(loopRun); - if (!runCanStartAttempts(loopRun.status) || loopGate.blocked) break; - this.tick({ runId }); - const effectiveCapState = computeEffectiveParallelismCap(); - const effectiveCap = effectiveCapState.cap; - const capSignature = `${effectiveCap}:${effectiveCapState.reasons.join(",")}`; - if (capSignature !== (autopilotCapSignatures.get(runId) ?? "")) { - autopilotCapSignatures.set(runId, capSignature); - appendTimelineEvent({ - runId, - eventType: "autopilot_parallelism_cap_adjusted", - reason: effectiveCapState.reasons[0] ?? "configured_cap", - detail: { - configuredCap: parallelismCap, - effectiveCap, - reasons: effectiveCapState.reasons - } - }); - } - let runningAttemptCount = listAttemptRows(runId) - .map(toAttempt) - .filter((attempt) => attempt.status === "running").length; - if (runningAttemptCount >= effectiveCap) break; - - const readySteps = filterExecutionSteps(listStepRows(runId) - .map(toStep)) - .filter((step) => step.status === "ready") - .filter((step) => loopGate.recoveryMode !== "planner_plan_missing" || isPlanningRecoveryStep(step)) - .sort(readyStepOrderComparator); - if (!readySteps.length) break; - - // Phase 1 (sequential): pre-validate steps and collect spawn descriptors - // up to the effective cap. This keeps freshness checks and manual-step - // filtering sequential so we don't over-commit slots. - const spawnDescriptors: Array<{ step: typeof readySteps[number]; executor: OrchestratorExecutorKind }> = []; - for (const step of readySteps) { - if (runningAttemptCount + spawnDescriptors.length >= effectiveCap) break; - const fresh = getStepRow(step.id); - if (!fresh) continue; - if (toStep(fresh).status !== "ready") continue; - const stepMetadata = asRecord(step.metadata); - if (supersedeAutoSpawnedValidationStepIfTargetPassed({ - runId, - step, - reason: "autopilot_ready_validation_target_passed" - })) { - continue; - } - if (asBool(stepMetadata?.isMilestone, false)) { - const contract = asRecord(stepMetadata?.validationContract); - const validationCriteria = - typeof contract?.criteria === "string" - ? contract.criteria.trim() - : typeof stepMetadata?.validationCriteria === "string" - ? stepMetadata.validationCriteria.trim() - : ""; - appendTimelineEvent({ - runId, - stepId: step.id, - eventType: "autopilot_step_skipped", - reason: "milestone_requires_validation_gate", - detail: { - stepKey: step.stepKey, - validationCriteria: validationCriteria || null - } - }); - continue; - } - const explicitStepExecutor = - typeof step.metadata?.executorKind === "string" - ? normalizeExecutorKind(String(step.metadata.executorKind)) - : null; - const stepExecutor = explicitStepExecutor ?? autopilot.executorKind; - if (stepExecutor === "manual") { - appendTimelineEvent({ - runId, - stepId: step.id, - eventType: "autopilot_step_skipped", - reason: "manual_step_requires_operator", - detail: { - stepKey: step.stepKey - } - }); - continue; - } - spawnDescriptors.push({ step, executor: stepExecutor }); - } - - // Phase 2 (parallel): spawn all validated steps concurrently. - // Claims inside startAttempt are synchronous SQLite operations and - // serialize naturally. Context snapshots and PTY sessions are the - // expensive async work that benefits from parallelism. - // Promise.allSettled ensures one failure does not cancel others. - let startedInLoop = 0; - if (spawnDescriptors.length > 0) { - const results = await Promise.allSettled( - spawnDescriptors.map(({ step, executor }) => { - if (supersedeAutoSpawnedValidationStepIfTargetPassed({ - runId, - step, - reason: "autopilot_pre_start_validation_target_passed" - })) { - return Promise.resolve({ ok: false as const, step, executor }); - } - return this.startAttempt({ - runId, - stepId: step.id, - ownerId: autopilot.ownerId, - executorKind: executor - }).then( - () => ({ ok: true as const, step, executor }), - (error: unknown) => { - appendTimelineEvent({ - runId, - stepId: step.id, - eventType: "autopilot_attempt_start_failed", - reason: "autopilot_start_failed", - detail: { - message: error instanceof Error ? error.message : String(error) - } - }); - return { ok: false as const, step, executor }; - } - ); - }) - ); - for (const result of results) { - // allSettled with the inner .then() catch means all results are - // "fulfilled" — the inner catch converts rejections to { ok: false }. - if (result.status === "fulfilled" && result.value.ok) { - startedAttempts += 1; - startedInLoop += 1; - runningAttemptCount += 1; - startedByExecutor[result.value.executor] = - (startedByExecutor[result.value.executor] ?? 0) + 1; - } - } - } - if (startedInLoop === 0) break; - } - - if (startedAttempts > 0) { - appendTimelineEvent({ - runId, - eventType: "autopilot_advance", - reason: args.reason ?? "autopilot_advance", - detail: { - startedAttempts, - executorKind: autopilot.executorKind, - executorRouting: "step_policy", - startedByExecutor, - parallelismCap: parallelismCap - } - }); - } - - this.tick({ runId }); - return startedAttempts; - } finally { - autopilotRunLocks.delete(runId); - } - }, - - async onTrackedSessionEnded(args: { sessionId: string; laneId?: string | null; exitCode: number | null }): Promise<number> { - const sessionId = String(args.sessionId ?? "").trim(); - if (!sessionId.length) return 0; - const sessionRow = db.get<{ status: string | null; exit_code: number | null; transcript_path: string | null }>( - ` - select status, exit_code, transcript_path - from terminal_sessions - where id = ? - limit 1 - `, - [sessionId] - ); - const sessionStatus = normalizeTerminalSessionStatus(sessionRow?.status); - const resolvedExitCode = (() => { - const fromArgs = Number(args.exitCode); - if (Number.isFinite(fromArgs)) return Math.floor(fromArgs); - const fromRow = Number(sessionRow?.exit_code); - if (Number.isFinite(fromRow)) return Math.floor(fromRow); - return null; - })(); - const runningAttempts = db.all<AttemptRow>( - ` - select - id, - run_id, - step_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - from orchestrator_attempts - where project_id = ? - and executor_session_id = ? - and status = 'running' - order by created_at asc - `, - [projectId, sessionId] - ); - if (!runningAttempts.length) return 0; - - const completion = (() => { - if (resolvedExitCode != null) { - if (resolvedExitCode === 0) { - return { - status: "succeeded" as const, - errorClass: null, - errorMessage: null - }; - } - return { - status: "failed" as const, - errorClass: "executor_failure" as const, - errorMessage: `Tracked session exited with code ${resolvedExitCode}.` - }; - } - if (sessionStatus === "completed") { - return { - status: "succeeded" as const, - errorClass: null, - errorMessage: null - }; - } - if (sessionStatus === "disposed") { - return { - status: "canceled" as const, - errorClass: "canceled" as const, - errorMessage: "Tracked session was disposed before completion." - }; - } - return { - status: "failed" as const, - errorClass: "executor_failure" as const, - errorMessage: - sessionStatus === "failed" - ? "Tracked session reported failed status." - : "Tracked session ended unexpectedly without an exit code." - }; - })(); - - const touchedRunIds = new Set<string>(); - for (const attempt of runningAttempts) { - touchedRunIds.add(attempt.run_id); - const stepRow = getStepRow(attempt.step_id); - const step = stepRow ? toStep(stepRow) : null; - let stepMetadata = asRecord(step?.metadata) ?? {}; - const attemptMetadata = parseJsonRecord(attempt.metadata_json); - const transcriptPath = - typeof attemptMetadata?.transcriptPath === "string" - ? attemptMetadata.transcriptPath.trim() - : (typeof sessionRow?.transcript_path === "string" ? sessionRow.transcript_path.trim() : ""); - const transcriptAnalysis = analyzeTranscriptFromPath(transcriptPath); - const transcriptSummary = transcriptAnalysis.summary; - 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) { - recoveredPayload = extractReportResultPayloadFromWorkerStepOutput({ - worktreePath, - stepKey: step.stepKey, - attemptId: attempt.id, - }); - 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<string, unknown>, - }); - 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<string, unknown>, - }); - 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<string, unknown>, - }); - hasStructuredResult = true; - } - } - const silentFailure = hasStructuredResult - ? null - : classifySilentWorkerExit({ - stepMetadata, - transcriptSummary, - hasMaterialOutput: transcriptAnalysis.hasMaterialOutput, - hasLifecycleActivity: transcriptAnalysis.hasLifecycleActivity, - appearsInterruptedDuringCommand: transcriptAnalysis.appearsInterruptedDuringCommand, - }); - const completionForAttempt = - completion.status === "succeeded" && silentFailure - ? { - status: "failed" as const, - errorClass: silentFailure.errorClass, - errorMessage: silentFailure.errorMessage, - } - : completion; - persistRuntimeEvent({ - runId: attempt.run_id, - stepId: attempt.step_id, - attemptId: attempt.id, - sessionId, - eventType: "session_ended", - eventKey: `session_ended:${attempt.id}:${sessionId}:${sessionStatus}:${resolvedExitCode ?? "none"}`, - payload: { - sessionStatus, - exitCode: resolvedExitCode - } - }); - await this.completeAttempt({ - attemptId: attempt.id, - status: completionForAttempt.status, - ...(completionForAttempt.errorClass - ? { - errorClass: completionForAttempt.errorClass, - errorMessage: completionForAttempt.errorMessage - } - : {}), - metadata: { - reconciledFromTrackedSession: true, - trackedSessionId: sessionId, - transcriptPath: transcriptPath || null, - laneId: args.laneId ?? null, - exitCode: resolvedExitCode, - sessionStatus - } - }); - } - - for (const runId of touchedRunIds) { - await this.startReadyAutopilotAttempts({ - runId, - reason: "session_ended" - }); - } - - return runningAttempts.length; - }, - - evaluateGateReport(): OrchestratorGateReport { - const now = Date.now(); - const gateEntries: OrchestratorGateEntry[] = []; - const notes: string[] = []; - - const pipelineRows = db.all<{ - session_id: string; - ended_at: string | null; - delta_at: string | null; - checkpoint_at: string | null; - }>( - ` - select - s.id as session_id, - s.ended_at as ended_at, - ( - select d.computed_at - from session_deltas d - where d.project_id = ? - and d.session_id = s.id - order by d.computed_at desc - limit 1 - ) as delta_at, - ( - select c.created_at - from checkpoints c - where c.project_id = ? - and c.session_id = s.id - order by c.created_at desc - limit 1 - ) as checkpoint_at - from terminal_sessions s - join lanes l on l.id = s.lane_id - where l.project_id = ? - and s.tracked = 1 - and s.ended_at is not null - order by s.ended_at desc - limit 400 - `, - [projectId, projectId, projectId] - ); - const pipelineSamples = pipelineRows - .map((row) => { - const endedAt = row.ended_at ? Date.parse(row.ended_at) : NaN; - if (!Number.isFinite(endedAt)) return null; - const materializedAt = [row.delta_at, row.checkpoint_at] - .map((value) => (value ? Date.parse(value) : NaN)) - .filter((value) => Number.isFinite(value)); - if (!materializedAt.length) return null; - return Math.max(0, Math.max(...materializedAt) - endedAt); - }) - .filter((value): value is number => Number.isFinite(value)); - const pipelineWithin = pipelineSamples.filter((value) => value <= GATE_THRESHOLDS.maxTrackedPipelineLatencyMs).length; - const pipelineRate = pipelineSamples.length > 0 ? pipelineWithin / pipelineSamples.length : 0; - const averagePipelineLatency = - pipelineSamples.length > 0 ? Math.round(pipelineSamples.reduce((sum, value) => sum + value, 0) / pipelineSamples.length) : 0; - gateEntries.push({ - key: "session_delta_checkpoint_pack_latency", - label: "Tracked session -> delta/checkpoint latency", - status: - pipelineSamples.length === 0 - ? "warn" - : averagePipelineLatency <= GATE_THRESHOLDS.maxTrackedPipelineLatencyMs - ? "pass" - : "fail", - measuredValue: averagePipelineLatency, - threshold: GATE_THRESHOLDS.maxTrackedPipelineLatencyMs, - comparator: "<=", - samples: pipelineSamples.length, - reasons: - pipelineSamples.length === 0 - ? ["No tracked session pipeline samples were available."] - : averagePipelineLatency <= GATE_THRESHOLDS.maxTrackedPipelineLatencyMs - ? [] - : [`Average latency ${averagePipelineLatency}ms exceeded threshold (${GATE_THRESHOLDS.maxTrackedPipelineLatencyMs}ms).`], - metadata: { - withinBudgetRate: pipelineSamples.length > 0 ? pipelineRate : 0 - } - }); - - const snapshotRows = db.all<{ created_at: string }>( - ` - select created_at - from orchestrator_context_snapshots - where project_id = ? - order by created_at desc - limit 400 - `, - [projectId] - ); - const freshnessWindowMs = GATE_THRESHOLDS.freshnessMaxAgeByPackTypeMs.lane; - const freshCount = snapshotRows.filter((row) => { - const updatedAt = row.created_at ? Date.parse(row.created_at) : NaN; - if (!Number.isFinite(updatedAt)) return false; - return now - updatedAt <= freshnessWindowMs; - }).length; - const freshnessRate = snapshotRows.length > 0 ? freshCount / snapshotRows.length : 0; - gateEntries.push({ - key: "pack_freshness_by_type", - label: "Live context snapshot freshness", - status: - snapshotRows.length === 0 - ? "warn" - : freshnessRate >= GATE_THRESHOLDS.minFreshnessByTypeRate - ? "pass" - : "fail", - measuredValue: Number(freshnessRate.toFixed(4)), - threshold: GATE_THRESHOLDS.minFreshnessByTypeRate, - comparator: ">=", - samples: snapshotRows.length, - reasons: - snapshotRows.length === 0 - ? ["No context snapshots exist yet."] - : freshnessRate >= GATE_THRESHOLDS.minFreshnessByTypeRate - ? [] - : [`Fresh context snapshots ${freshCount}/${snapshotRows.length} fell below threshold.`], - metadata: { - freshCount, - total: snapshotRows.length, - freshnessWindowMs - } - }); - - const attemptRows = db.all<{ context_snapshot_id: string | null; cursor_json: string | null; status: string }>( - ` - select - a.context_snapshot_id, - a.status, - s.cursor_json - from orchestrator_attempts a - left join orchestrator_context_snapshots s on s.id = a.context_snapshot_id - where a.project_id = ? - and a.status in ('running', 'succeeded', 'failed', 'blocked', 'canceled') - `, - [projectId] - ); - const completeCount = attemptRows.filter((row) => { - if (!row.context_snapshot_id || !row.cursor_json) return false; - try { - const cursor = JSON.parse(row.cursor_json) as OrchestratorContextSnapshotCursor; - return Boolean(cursor.projectPackVersionId) && Array.isArray(cursor.docs) && cursor.docs.length > 0; - } catch { - return false; - } - }).length; - const completenessRate = attemptRows.length > 0 ? completeCount / attemptRows.length : 0; - gateEntries.push({ - key: "context_completeness_rate", - label: "Context completeness rate for orchestrated steps", - status: - attemptRows.length === 0 - ? "warn" - : completenessRate >= GATE_THRESHOLDS.minContextCompletenessRate - ? "pass" - : "fail", - measuredValue: Number(completenessRate.toFixed(4)), - threshold: GATE_THRESHOLDS.minContextCompletenessRate, - comparator: ">=", - samples: attemptRows.length, - reasons: - attemptRows.length === 0 - ? ["No orchestrator attempts exist yet."] - : completenessRate >= GATE_THRESHOLDS.minContextCompletenessRate - ? [] - : [`Only ${completeCount}/${attemptRows.length} attempts had context snapshots.`] - }); - - const runCount = Number( - db.get<{ count: number }>("select count(*) as count from orchestrator_runs where project_id = ?", [projectId])?.count ?? 0 - ); - const insufficientRows = db.all<{ - run_id: string; - error_message: string | null; - metadata_json: string | null; - }>( - ` - select run_id, error_message, metadata_json - from orchestrator_attempts - where project_id = ? - and status = 'blocked' - and ( - error_message like '%insufficient%' - or metadata_json like '%insufficient_context%' - or metadata_json like '%insufficientContext%' - ) - `, - [projectId] - ); - const blockedRunIds = new Set<string>(); - const reasonCodes = new Set<string>(); - for (const row of insufficientRows) { - if (row.run_id) blockedRunIds.add(row.run_id); - const metadata = parseJsonRecord(row.metadata_json); - const rawCodes = Array.isArray(metadata?.reasonCodes) - ? (metadata?.reasonCodes as unknown[]) - : Array.isArray(metadata?.insufficientReasons) - ? (metadata?.insufficientReasons as unknown[]) - : []; - for (const code of rawCodes) reasonCodes.add(String(code)); - if (typeof row.error_message === "string" && row.error_message.trim().length) { - reasonCodes.add(row.error_message.trim()); - } - } - const blockedRate = runCount > 0 ? blockedRunIds.size / runCount : 0; - gateEntries.push({ - key: "blocked_run_rate_insufficient_context", - label: "Blocked-run rate due to insufficient context", - status: runCount === 0 ? "warn" : blockedRate <= GATE_THRESHOLDS.maxBlockedInsufficientContextRate ? "pass" : "fail", - measuredValue: Number(blockedRate.toFixed(4)), - threshold: GATE_THRESHOLDS.maxBlockedInsufficientContextRate, - comparator: "<=", - samples: runCount, - reasons: - runCount === 0 - ? ["No orchestrator runs exist yet."] - : blockedRate <= GATE_THRESHOLDS.maxBlockedInsufficientContextRate - ? [] - : [`Blocked runs ${blockedRunIds.size}/${runCount} exceeded threshold.`], - metadata: { - reasonCodes: [...reasonCodes] - } - }); - - const overallStatus: OrchestratorGateStatus = gateEntries.some((entry) => entry.status === "fail") - ? "fail" - : gateEntries.some((entry) => entry.status === "warn") - ? "warn" - : "pass"; - if (overallStatus !== "pass") { - notes.push("Phase 1.5 quality gates are not fully passing."); - } - - const report: OrchestratorGateReport = { - id: randomUUID(), - generatedAt: nowIso(), - generatedBy: "deterministic_kernel", - overallStatus, - gates: gateEntries, - notes - }; - db.run( - ` - insert into orchestrator_gate_reports( - id, - project_id, - generated_at, - report_json - ) values (?, ?, ?, ?) - `, - [report.id, projectId, report.generatedAt, JSON.stringify(report)] - ); - return report; - }, - - getLatestGateReport(args: { refresh?: boolean } = {}): OrchestratorGateReport { - if (args.refresh === true) { - return this.evaluateGateReport(); - } - const latest = db.get<GateReportRow>( - ` - select id, generated_at, report_json - from orchestrator_gate_reports - where project_id = ? - order by generated_at desc - limit 1 - `, - [projectId] - ); - const parsed = latest ? toGateReport(latest) : null; - if (parsed) return parsed; - return this.evaluateGateReport(); - }, - - startRun(args: StartOrchestratorRunArgs): { run: OrchestratorRun; steps: OrchestratorStep[] } { - const missionId = String(args.missionId ?? "").trim(); - if (!missionId) throw new Error("missionId is required."); - const mission = db.get<{ id: string }>( - ` - select id - from missions - where id = ? - and project_id = ? - limit 1 - `, - [missionId, projectId] - ); - if (!mission?.id) throw new Error(`Mission not found: ${missionId}`); - - const runId = String(args.runId ?? "").trim() || randomUUID(); - const profileId = normalizeProfileId(args.contextProfile); - const createdAt = nowIso(); - const schedulerState = String(args.schedulerState ?? "initialized").trim() || "initialized"; - const rawMetadata = args.metadata ?? {}; - const phaseCards = resolveRunPhaseCardsFromMetadata(rawMetadata); - const metadata: Record<string, unknown> = { ...rawMetadata }; - if (phaseCards && !asRecord(metadata.phaseRuntime)) { - const phaseRuntime = buildInitialPhaseRuntime(phaseCards); - if (phaseRuntime) metadata.phaseRuntime = phaseRuntime; - } - - const byKey = new Map<string, string>(); - const dependencyStepKeysByStepKey = new Map<string, string[]>(); - const stepRows = [...args.steps] - .sort((a, b) => a.stepIndex - b.stepIndex || a.stepKey.localeCompare(b.stepKey)) - .map((input, index) => { - const id = randomUUID(); - const stepKey = input.stepKey.trim(); - if (!stepKey) throw new Error("stepKey is required for every orchestrator step."); - if (byKey.has(stepKey)) throw new Error(`Duplicate stepKey in run: ${stepKey}`); - byKey.set(stepKey, id); - const dependencyStepKeys = normalizeDependencyStepKeys(input.dependencyStepKeys); - dependencyStepKeysByStepKey.set(stepKey, dependencyStepKeys); - return { - id, - input, - createdAt, - order: Number.isFinite(input.stepIndex) ? input.stepIndex : index, - stepKey, - dependencyStepKeys - }; - }); - - validateStepGraphIntegrity({ - context: "startRun", - steps: stepRows.map(({ stepKey, dependencyStepKeys, input }) => ({ - stepKey, - dependencyStepKeys, - joinPolicy: normalizeJoinPolicy(String(input.joinPolicy ?? "all_success")), - quorumCount: input.quorumCount != null ? Math.floor(Number(input.quorumCount)) : null - })) - }); - - db.run( - ` - insert into orchestrator_runs( - id, - project_id, - mission_id, - status, - context_profile, - scheduler_state, - runtime_cursor_json, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, 'queued', ?, ?, null, null, ?, ?, ?, null, null) - `, - [runId, projectId, missionId, profileId, schedulerState, JSON.stringify(metadata), createdAt, createdAt] - ); - appendTimelineEvent({ - runId, - eventType: "run_created", - reason: "start_run", - detail: { - missionId, - contextProfile: profileId, - schedulerState - } - }); - - for (const { id, input, createdAt: created, stepKey } of stepRows) { - const policy: Record<string, unknown> = { - ...(typeof input.policy?.docsMaxBytes === "number" ? { docsMaxBytes: Math.floor(input.policy.docsMaxBytes) } : {}), - claimScopes: Array.isArray(input.policy?.claimScopes) - ? input.policy?.claimScopes?.map((scope) => ({ - scopeKind: scope.scopeKind, - scopeValue: scope.scopeValue, - ...(typeof scope.ttlMs === "number" ? { ttlMs: Math.floor(scope.ttlMs) } : {}) - })) - : [] - }; - const stepMetadata = normalizePlanningQuestionStepMetadata({ - missionId, - runMetadata: metadata, - metadata: { - ...(input.metadata ?? {}), - ...(input.executorKind ? { executorKind: input.executorKind } : {}), - policy - }, - }); - const metadataJson = JSON.stringify(stepMetadata); - db.run( - ` - insert into orchestrator_steps( - id, - run_id, - project_id, - mission_step_id, - step_key, - step_index, - title, - lane_id, - status, - join_policy, - quorum_count, - dependency_step_ids_json, - retry_limit, - retry_count, - last_attempt_id, - policy_json, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, '[]', ?, 0, null, ?, ?, ?, ?, null, null) - `, - [ - id, - runId, - projectId, - input.missionStepId ?? null, - stepKey, - Number.isFinite(input.stepIndex) ? Math.floor(input.stepIndex) : 0, - input.title.trim() || stepKey, - input.laneId ?? null, - input.joinPolicy ?? "all_success", - input.quorumCount ?? null, - Math.max(0, Math.floor(input.retryLimit ?? 0)), - JSON.stringify(policy), - metadataJson, - created, - created - ] - ); - appendTimelineEvent({ - runId, - stepId: id, - eventType: "step_registered", - reason: "start_run", - detail: { - stepKey, - stepIndex: Number.isFinite(input.stepIndex) ? Math.floor(input.stepIndex) : 0, - joinPolicy: input.joinPolicy ?? "all_success", - retryLimit: Math.max(0, Math.floor(input.retryLimit ?? 0)) - } - }); - } - - // Fill resolved dependency IDs after all step rows exist. - for (const { id, stepKey } of stepRows) { - const depKeys = dependencyStepKeysByStepKey.get(stepKey) ?? []; - const depIds = depKeys.map((key) => { - const depId = byKey.get(key); - if (!depId) { - throw new Error(`Unknown dependency stepKey '${key}' referenced by step '${stepKey}'.`); - } - return depId; - }); - db.run( - ` - update orchestrator_steps - set dependency_step_ids_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(depIds), createdAt, id, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId: id, - eventType: "step_dependencies_resolved", - reason: "start_run", - detail: { - dependencyStepIds: depIds - } - }); - } - - const run = toRun( - getRunRow(runId) ?? - ({ - id: runId, - mission_id: missionId, - project_id: projectId, - status: "queued", - context_profile: profileId, - scheduler_state: schedulerState, - runtime_cursor_json: null, - last_error: null, - metadata_json: JSON.stringify(metadata), - created_at: createdAt, - updated_at: createdAt, - started_at: null, - completed_at: null - } satisfies RunRow) - ); - - this.tick({ runId }); - - return { - run, - steps: listStepRows(runId).map(toStep) - }; - }, - - tick(args: { runId: string }): OrchestratorRun { - const run = getRunRow(args.runId); - if (!run) throw new Error(`Run not found: ${args.runId}`); - const runStatus = normalizeRunStatus(run.status); - if (TERMINAL_RUN_STATUSES.has(runStatus)) return toRun(run); - // Paused runs (e.g. budget exceeded) should not auto-resume via tick - if (runStatus === "paused") return toRun(run); - // "completing" runs are awaiting explicit finalizeRun — tick should not interfere - if (runStatus === "completing") return toRun(run); - - // ── Maintenance only: claim expiry + step readiness refresh ── - expireClaims(); - settleStaleAutoSpawnedValidationSteps(args.runId); - refreshStepReadiness(args.runId); - closeTerminalWorkerThreadsForRun(args.runId); - - // Promote queued → bootstrapping (or active if no planning needed) - const current = runStatus; - if (current === "queued") { - updateRunStatus(args.runId, "bootstrapping"); - } - - // tick() does NOT transition runs to terminal states. - // Terminal transitions are exclusively via finalizeRun() or cancelRun(). - // However, tick still updates the timestamp for liveness tracking. - db.run( - ` - update orchestrator_runs - set updated_at = ? - where id = ? - and project_id = ? - `, - [nowIso(), args.runId, projectId] - ); - - appendTimelineEvent({ - runId: args.runId, - eventType: "scheduler_tick", - reason: "tick", - detail: { - fromStatus: current, - maintenance: true - } - }); - - const updated = getRunRow(args.runId); - if (!updated) throw new Error(`Run not found after tick: ${args.runId}`); - return toRun(updated); - }, - - heartbeatClaims(args: { attemptId: string; ownerId: string }): number { - const now = nowIso(); - const runtimeConfig = getRuntimeConfig(); - const activeClaims = db.all<ClaimRow>( - ` - select - id, - run_id, - step_id, - attempt_id, - owner_id, - scope_kind, - scope_value, - state, - acquired_at, - heartbeat_at, - expires_at, - released_at, - policy_json, - metadata_json - from orchestrator_claims - where project_id = ? - and attempt_id = ? - and owner_id = ? - and state = 'active' - `, - [projectId, args.attemptId, args.ownerId] - ); - for (const claim of activeClaims) { - const policy = parseJsonRecord(claim.policy_json) ?? {}; - const ttlMsRaw = Number(policy.ttlMs ?? runtimeConfig.workerHeartbeatTimeoutMs); - const ttlMs = - Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? ttlMsRaw : runtimeConfig.workerHeartbeatTimeoutMs; - db.run( - ` - update orchestrator_claims - set heartbeat_at = ?, - expires_at = ? - where id = ? - and project_id = ? - and state = 'active' - `, - [now, new Date(Date.now() + ttlMs).toISOString(), claim.id, projectId] - ); - appendTimelineEvent({ - runId: claim.run_id, - stepId: claim.step_id, - attemptId: claim.attempt_id, - claimId: claim.id, - eventType: "claim_heartbeat", - reason: "heartbeat", - detail: { - ownerId: claim.owner_id, - ttlMs - } - }); - } - if (activeClaims.length) { - emit({ type: "orchestrator-claim-updated", attemptId: args.attemptId, reason: "heartbeat" }); - } - return activeClaims.length; - }, - - async startAttempt(args: { - runId: string; - stepId: string; - ownerId: string; - executorKind?: OrchestratorExecutorKind; - }): Promise<OrchestratorAttempt> { - const runtimeConfig = getRuntimeConfig(); - const runRow = getRunRow(args.runId); - if (!runRow) throw new Error(`Run not found: ${args.runId}`); - const stepRow = getStepRow(args.stepId); - if (!stepRow || stepRow.run_id !== args.runId) throw new Error(`Step not found in run: ${args.stepId}`); - - const run = toRun(runRow); - let step = toStep(stepRow); - if (run.status === "paused") { - throw new Error(`Cannot start attempt for run in status 'paused' (run ${run.id} is paused).`); - } - if (!runCanStartAttempts(run.status)) { - throw new Error(`Cannot start attempt for run in status '${run.status}'.`); - } - if (step.status !== "ready") { - throw new Error(`Step is not ready: ${step.id} (${step.status})`); - } - if (supersedeAutoSpawnedValidationStepIfTargetPassed({ - runId: run.id, - step, - reason: "start_attempt_validation_target_passed" - })) { - throw new Error( - `Validation step '${step.stepKey}' is no longer needed because the target step already has a passing validation report.`, - ); - } - - // ── Populate handoff summaries from predecessor worker digests ── - if (step.dependencyStepIds.length > 0) { - const HANDOFF_SUMMARY_MAX_CHARS = 500; - const depIds = step.dependencyStepIds; - const placeholders = depIds.map(() => "?").join(", "); - - // For each dependency step, get the latest succeeded/failed worker digest. - // We use a subquery with ROW_NUMBER to pick the most recent digest per step. - const digestRows = db.all<{ - step_id: string; - step_key: string | null; - status: string; - summary: string; - files_changed_json: string | null; - tests_run_json: string | null; - }>( - ` - select d.step_id, d.step_key, d.status, d.summary, d.files_changed_json, d.tests_run_json - from orchestrator_worker_digests d - inner join ( - select step_id, max(created_at) as max_created - from orchestrator_worker_digests - where project_id = ? - and mission_id = ? - and run_id = ? - and step_id in (${placeholders}) - and status in ('succeeded', 'failed') - group by step_id - ) latest on d.step_id = latest.step_id and d.created_at = latest.max_created - where d.project_id = ? - and d.mission_id = ? - and d.run_id = ? - `, - [ - projectId, run.missionId, run.id, - ...depIds, - projectId, run.missionId, run.id - ] - ); - - if (digestRows.length > 0) { - const handoffSummaries: string[] = digestRows.map((row) => { - const stepLabel = row.step_key ?? row.step_id.slice(0, 8); - const status = String(row.status ?? "unknown").trim(); - const summaryText = clipText(String(row.summary ?? "").trim(), HANDOFF_SUMMARY_MAX_CHARS); - - const filesChanged: string[] = (() => { - if (!row.files_changed_json) return []; - try { - const parsed = JSON.parse(row.files_changed_json); - return Array.isArray(parsed) ? parsed.map((f: unknown) => String(f ?? "").trim()).filter(Boolean) : []; - } catch { return []; } - })(); - - const testsRun: { passed?: number; failed?: number; skipped?: number; summary?: string | null } = (() => { - if (!row.tests_run_json) return {}; - try { - const parsed = JSON.parse(row.tests_run_json); - return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {}; - } catch { return {}; } - })(); - - const parts: string[] = [`[${stepLabel}] (${status}) ${summaryText}`]; - - if (filesChanged.length > 0) { - const fileList = filesChanged.length <= 5 - ? filesChanged.join(", ") - : `${filesChanged.slice(0, 5).join(", ")} (+${filesChanged.length - 5} more)`; - parts.push(`Files: ${fileList}`); - } - - const testPassed = Number(testsRun.passed ?? 0); - const testFailed = Number(testsRun.failed ?? 0); - const testSkipped = Number(testsRun.skipped ?? 0); - if (testPassed > 0 || testFailed > 0 || testSkipped > 0) { - const testParts: string[] = []; - if (testPassed > 0) testParts.push(`${testPassed} passed`); - if (testFailed > 0) testParts.push(`${testFailed} failed`); - if (testSkipped > 0) testParts.push(`${testSkipped} skipped`); - parts.push(`Tests: ${testParts.join(", ")}`); - } - - return clipText(parts.join(" | "), HANDOFF_SUMMARY_MAX_CHARS); - }); - - // Persist updated metadata with handoff summaries so the adapter can read them - const updatedMetadata = { - ...(step.metadata ?? {}), - handoffSummaries - }; - db.run( - ` - update orchestrator_steps - set metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(updatedMetadata), nowIso(), step.id, run.id, projectId] - ); - // Re-read the step so downstream code sees the updated metadata - const refreshedStepRow = getStepRow(step.id); - if (refreshedStepRow) { - step = toStep(refreshedStepRow); - } - } - } - - const attemptNumRow = db.get<{ max_attempt: number }>( - ` - select max(attempt_number) as max_attempt - from orchestrator_attempts - where project_id = ? - and step_id = ? - `, - [projectId, step.id] - ); - const attemptNumber = Number(attemptNumRow?.max_attempt ?? 0) + 1; - const attemptId = randomUUID(); - const createdAt = nowIso(); - const executorKind = normalizeExecutorKind( - String(args.executorKind ?? step.metadata?.executorKind ?? "manual"), - ); - const runMissionLaneId = run.missionId ? resolveRunMissionLaneId(run.metadata) : null; - const registeredAdapter = adapters.get(executorKind) ?? null; - const defaultAdapter = registeredAdapter ? null : defaultAdapterFor(executorKind); - const requiresLaneId = - executorKind !== "manual" - && (registeredAdapter?.requiresLaneId === true || defaultAdapter?.requiresLaneId === true); - if (!step.laneId) { - if (runMissionLaneId && executorKind !== "manual") { - throw new Error( - `Mission step '${step.stepKey}' cannot start with executor '${executorKind}' because the dedicated mission lane is missing.`, - ); - } - if (requiresLaneId) { - throw new Error( - `Step '${step.stepKey}' cannot start with executor '${executorKind}' because laneId is missing.`, - ); - } - } - const stepPolicy = resolveStepPolicy(step); - const contextPolicy = resolveContextPolicy({ stepPolicy }); - - // Insert the attempt row early so that all downstream tables with - // foreign key(attempt_id) references orchestrator_attempts(id) — - // including orchestrator_claims, orchestrator_timeline_events, - // orchestrator_runtime_events, and mission_step_handoffs — can - // reference it without FK constraint violations. - // We start with status='running' and update to 'blocked' if claims fail, - // and backfill context_snapshot_id after snapshot creation. - db.run( - ` - insert into orchestrator_attempts( - id, - run_id, - step_id, - project_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, 'running', ?, null, 1, ?, null, 'none', null, 0, null, ?, ?, ?, null) - `, - [ - attemptId, - run.id, - step.id, - projectId, - attemptNumber, - executorKind, - contextPolicy.id, - JSON.stringify({ - ownerId: args.ownerId, - workerState: "initializing", - workerStartedAt: createdAt - }), - createdAt, - createdAt - ] - ); - - // Claims are acquired before attempt state transitions so collisions are deterministic. - const acquiredClaims: OrchestratorClaim[] = []; - for (const scope of stepPolicy.claimScopes ?? []) { - const normalizedScopeValue = normalizeClaimScopeValue({ - scopeKind: scope.scopeKind, - scopeValue: scope.scopeValue - }); - const failClaimStart = (failure: { - errorMessage: string; - detail: Record<string, unknown>; - reason: string; - }): OrchestratorAttempt => { - releaseClaimsForAttempt({ attemptId, state: "released" }); - db.run( - ` - update orchestrator_attempts - set status = 'blocked', - error_class = 'claim_conflict', - error_message = ?, - metadata_json = ?, - completed_at = ? - where id = ? - and project_id = ? - `, - [ - failure.errorMessage, - JSON.stringify({ - ownerId: args.ownerId, - claimScope: { - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue ?? scope.scopeValue, - ttlMs: scope.ttlMs - }, - workerState: "disposed", - claimConflict: failure.detail - }), - createdAt, - attemptId, - projectId - ] - ); - db.run( - ` - update orchestrator_steps - set status = 'blocked', - last_attempt_id = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [attemptId, createdAt, step.id, run.id, projectId] - ); - insertHandoff({ - missionId: run.missionId, - missionStepId: step.missionStepId, - runId: run.id, - stepId: step.id, - attemptId, - handoffType: "attempt_blocked", - producer: "orchestrator", - payload: { - reason: "claim_conflict", - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue ?? scope.scopeValue, - contextProfile: contextPolicy.id, - ...failure.detail - } - }); - emit({ type: "orchestrator-attempt-updated", runId: run.id, stepId: step.id, attemptId, reason: "claim_blocked" }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId, - eventType: "attempt_blocked", - reason: failure.reason, - detail: { - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue ?? scope.scopeValue, - ...failure.detail - } - }); - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId, - eventType: "claim_conflict", - eventKey: `claim_conflict:${attemptId}:${scope.scopeKind}:${normalizedScopeValue ?? scope.scopeValue}`, - occurredAt: createdAt, - payload: { - reason: failure.reason, - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue ?? scope.scopeValue, - ...failure.detail - } - }); - this.tick({ runId: run.id }); - const blockedRow = getAttemptRow(attemptId); - if (!blockedRow) throw new Error("Failed to create blocked attempt."); - return toAttempt(blockedRow); - }; - - if (!normalizedScopeValue) { - return failClaimStart({ - errorMessage: `Invalid file reservation scope: ${scope.scopeValue}`, - reason: "claim_scope_invalid", - detail: { - invalidScopeValue: scope.scopeValue - } - }); - } - const existingConflict = findActiveClaimConflict({ - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue, - ignoreAttemptId: attemptId - }); - if (existingConflict) { - return failClaimStart({ - errorMessage: `Claim collision for ${scope.scopeKind}:${normalizedScopeValue}`, - reason: "claim_conflict", - detail: { - conflictingClaimId: existingConflict.conflict.id, - conflictingRunId: existingConflict.conflict.run_id, - conflictingStepId: existingConflict.conflict.step_id, - conflictingAttemptId: existingConflict.conflict.attempt_id, - conflictingScopeValue: existingConflict.conflict.scope_value, - conflictReason: existingConflict.reason - } - }); - } - const ttlMs = scope.ttlMs ?? runtimeConfig.workerHeartbeatTimeoutMs; - const claim = acquireClaim({ - runId: run.id, - stepId: step.id, - attemptId, - ownerId: args.ownerId.trim() || "orchestrator", - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue, - ttlMs, - policy: { ttlMs } - }); - if (!claim) { - const postConflict = findActiveClaimConflict({ - scopeKind: scope.scopeKind, - scopeValue: normalizedScopeValue, - ignoreAttemptId: attemptId - }); - return failClaimStart({ - errorMessage: `Claim collision for ${scope.scopeKind}:${normalizedScopeValue}`, - reason: "claim_conflict", - detail: postConflict - ? { - conflictingClaimId: postConflict.conflict.id, - conflictingRunId: postConflict.conflict.run_id, - conflictingStepId: postConflict.conflict.step_id, - conflictingAttemptId: postConflict.conflict.attempt_id, - conflictingScopeValue: postConflict.conflict.scope_value, - conflictReason: postConflict.reason - } - : {} - }); - } - acquiredClaims.push(claim); - } - - // (Attempt row already inserted above at the early-insert site for FK satisfaction.) - - // Budget guard: check total token budget before dispatching - const tokensConsumed = Number(run.metadata?.tokensConsumed ?? 0); - if (runtimeConfig.maxTotalTokenBudget != null && tokensConsumed >= runtimeConfig.maxTotalTokenBudget) { - releaseClaimsForAttempt({ attemptId, state: "released" }); - updateRunStatus(run.id, "paused", { - last_error: `Total token budget exceeded: ${tokensConsumed} >= ${runtimeConfig.maxTotalTokenBudget}` - }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId, - eventType: "budget_exceeded", - reason: "total_budget_limit", - detail: { - tokensConsumed, - maxTotalTokenBudget: runtimeConfig.maxTotalTokenBudget - } - }); - throw new Error(`Total token budget exceeded: ${tokensConsumed} >= ${runtimeConfig.maxTotalTokenBudget}`); - } - - const snapshot = await createContextSnapshotForAttempt({ - run, - step, - attemptId, - contextProfile: contextPolicy - }); - - // Update attempt with the resolved snapshot id and full metadata - db.run( - ` - update orchestrator_attempts - set context_snapshot_id = ?, - metadata_json = ? - where id = ? - and project_id = ? - `, - [ - snapshot.snapshotId, - JSON.stringify({ - ownerId: args.ownerId, - docsMode: contextPolicy.docsMode, - docsCount: snapshot.docsRefs.length, - workerState: "initializing", - workerStartedAt: createdAt - }), - attemptId, - projectId - ] - ); - - db.run( - ` - update orchestrator_steps - set status = 'running', - last_attempt_id = ?, - updated_at = ?, - started_at = coalesce(started_at, ?) - where id = ? - and run_id = ? - and project_id = ? - `, - [attemptId, createdAt, createdAt, step.id, run.id, projectId] - ); - - insertHandoff({ - missionId: run.missionId, - missionStepId: step.missionStepId, - runId: run.id, - stepId: step.id, - attemptId, - handoffType: "attempt_started", - producer: "orchestrator", - payload: { - contextProfile: contextPolicy.id, - contextSnapshotId: snapshot.snapshotId, - docsMode: contextPolicy.docsMode, - docsRefs: snapshot.docsRefs, - laneExportLevel: contextPolicy.laneExportLevel ?? null, - projectExportLevel: contextPolicy.projectExportLevel, - claims: acquiredClaims.map((claim) => ({ - id: claim.id, - scopeKind: claim.scopeKind, - scopeValue: claim.scopeValue, - expiresAt: claim.expiresAt - })) - } - }); - - emit({ type: "orchestrator-step-updated", runId: run.id, stepId: step.id, reason: "attempt_started" }); - emit({ type: "orchestrator-attempt-updated", runId: run.id, stepId: step.id, attemptId, reason: "started" }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId, - eventType: "attempt_started", - reason: "attempt_started", - detail: { - executorKind, - contextProfile: contextPolicy.id, - contextSnapshotId: snapshot.snapshotId, - workerState: "initializing" - } - }); - - const attemptRow = getAttemptRow(attemptId); - if (!attemptRow) throw new Error("Attempt creation failed."); - const attempt = toAttempt(attemptRow); - const completeAndAdvance = async (completeArgs: { - attemptId: string; - status: Extract<OrchestratorAttemptStatus, "succeeded" | "failed" | "blocked" | "canceled">; - result?: OrchestratorAttemptResultEnvelope; - errorClass?: OrchestratorErrorClass; - errorMessage?: string; - metadata?: Record<string, unknown> | null; - }): Promise<OrchestratorAttempt> => { - const completedAttempt = await this.completeAttempt(completeArgs); - await this.startReadyAutopilotAttempts({ - runId: run.id, - reason: "attempt_completed_inline" - }); - return completedAttempt; - }; - - const integrationResult = await tryRunConflictResolverChain({ - run, - step, - attempt - }); - if (integrationResult) { - return completeAndAdvance({ - attemptId: attempt.id, - status: integrationResult.status, - result: integrationResult.result, - errorClass: integrationResult.errorClass, - errorMessage: integrationResult.errorMessage, - metadata: integrationResult.metadata ?? null - }); - } - - const mergeResult = await tryRunMergePrAutomation({ - run, - step, - attempt - }); - if (mergeResult) { - return completeAndAdvance({ - attemptId: attempt.id, - status: mergeResult.status, - result: mergeResult.result, - errorClass: mergeResult.errorClass, - errorMessage: mergeResult.errorMessage, - metadata: mergeResult.metadata ?? null - }); - } - - let providerStepModelId: string | null = null; - if (["claude", "codex", "cursor", "droid", "opencode"].includes(executorKind)) { - const stepModelRaw = typeof step.metadata?.modelId === "string" ? step.metadata.modelId.trim() : ""; - const phaseModel = asRecord(step.metadata?.phaseModel); - const phaseModelIdRaw = typeof phaseModel?.modelId === "string" ? phaseModel.modelId.trim() : ""; - const runMeta = asRecord(run.metadata); - const phaseRuntime = asRecord(runMeta?.phaseRuntime); - const runtimePhaseModel = asRecord(phaseRuntime?.currentPhaseModel); - const runtimePhaseModelIdRaw = typeof runtimePhaseModel?.modelId === "string" ? runtimePhaseModel.modelId.trim() : ""; - const providerModelRef = stepModelRaw || phaseModelIdRaw || runtimePhaseModelIdRaw; - if (!providerModelRef.length) { - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "execution_path_unsupported", - reason: "provider_model_missing", - detail: { - executorKind, - message: `Step '${step.stepKey}' is missing required metadata.modelId (and no phase model is available).` - } - }); - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: "policy", - errorMessage: `Step '${step.stepKey}' is missing required metadata.modelId.`, - metadata: { - executorKind, - adapterState: "model_id_missing" - } - }); - } - const descriptor = resolveModelDescriptor(providerModelRef); - providerStepModelId = descriptor?.id ?? null; - const executionPath = descriptor ? classifyWorkerExecutionPath(descriptor) : "api"; - if (!descriptor) { - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "execution_path_unsupported", - reason: "provider_model_unregistered", - detail: { - executorKind, - modelId: providerModelRef, - message: `Model '${providerModelRef}' is not registered.` - } - }); - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: "policy", - errorMessage: `Model '${providerModelRef}' is not registered.`, - metadata: { - executorKind, - adapterState: "model_not_registered", - modelId: providerModelRef - } - }); - } - - // API/local models execute in-process and complete attempts directly. - if (executionPath !== "cli") { - if (!aiIntegrationService) { - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage: "AI integration service is unavailable for in-process worker execution.", - metadata: { - executorKind, - modelId: descriptor.id, - executionPath, - adapterState: "in_process_unavailable", - } - }); - } - - // Resolve lane worktree path BEFORE building the prompt so the constraint - // can be injected into the worker prompt via step.metadata.laneWorktreePath. - const laneWorktreePath = (() => { - if (!step.laneId) return runMissionLaneId ? null : projectRoot; - const row = db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? and project_id = ? limit 1`, - [step.laneId, projectId], - ); - const worktree = typeof row?.worktree_path === "string" ? row.worktree_path.trim() : ""; - return worktree.length > 0 ? worktree : null; - })(); - - if (!laneWorktreePath) { - const errorMsg = runMissionLaneId - ? `Mission step '${step.stepKey}' cannot start without the dedicated mission lane/worktree.` - : `Lane '${step.laneId}' has no worktree_path configured. Cannot start worker for step '${step.stepKey}' without a valid worktree.`; - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "worker_failed", - reason: "worktree_configuration_error", - detail: { - executorKind, - modelId: descriptor.id, - executionPath, - laneId: step.laneId ?? null, - error: errorMsg, - } - }); - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: "configuration_error", - errorMessage: errorMsg, - metadata: { - executorKind, - modelId: descriptor.id, - executionPath, - adapterState: "worktree_path_missing", - laneId: step.laneId, - } - }); - } - - const resolvedCwd = laneWorktreePath ?? projectRoot; - - // Inject laneWorktreePath into step metadata for the prompt builder - const stepWithWorktree: typeof step = step.laneId && laneWorktreePath - ? { - ...step, - metadata: { - ...(step.metadata ?? {}), - laneWorktreePath, - }, - } - : step; - - const allSteps = listStepRows(run.id).map(toStep); - const promptPack = buildFullPrompt( - { - run, - step: stepWithWorktree, - attempt, - allSteps, - contextProfile: contextPolicy, - laneExport: snapshot.laneExport, - projectExport: snapshot.projectExport, - docsRefs: snapshot.docsRefs, - fullDocs: snapshot.fullDocs, - createTrackedSession: async () => { - throw new Error("In-process execution does not create tracked terminal sessions."); - }, - }, - "opencode", - { workerRuntime: "in_process" }, - ); - - const stepType = String(step.metadata?.stepType ?? step.metadata?.taskType ?? "").trim().toLowerCase(); - const taskType: AiTaskType = - stepType === "analysis" || stepType === "planning" - ? "planning" - : stepType === "review" || stepType === "test_review" || stepType === "review_test" - ? "review" - : stepType === "conflict" || stepType === "conflict_resolution" - ? "conflict_resolution" - : "implementation"; - - const reasoningEffort = (() => { - const fromMeta = typeof step.metadata?.reasoningEffort === "string" ? step.metadata.reasoningEffort.trim() : ""; - if (fromMeta.length > 0) return fromMeta; - const fromPhase = typeof phaseModel?.thinkingLevel === "string" ? phaseModel.thinkingLevel.trim() : ""; - return fromPhase.length > 0 ? fromPhase : undefined; - })(); - - const timeoutMs = Number.isFinite(Number(step.metadata?.timeoutMs)) - ? Math.max(1_000, Math.floor(Number(step.metadata?.timeoutMs))) - : runtimeConfig.stepTimeoutDefaultMs; - - const requiresPlanApproval = - step.metadata?.requiresPlanApproval === true || step.metadata?.coordinationPattern === "plan_then_implement"; - const readOnlyExecution = step.metadata?.readOnlyExecution === true || requiresPlanApproval; - - // Resolve in-process permission from project + mission config - const inProcessPermissionMode: "read-only" | "edit" | "full-auto" = (() => { - if (readOnlyExecution) return "read-only" as const; - // Build provider-specific permissions for this mission - const aiCfg = asRecord(projectConfigService?.get()?.effective?.ai); - const perms = asRecord(aiCfg?.permissions); - const projPerms = projectPermissionConfigFromRecord(perms); - let permissionConfig = projPerms; - if (run.missionId) { - try { - const mRow = db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? and project_id = ? limit 1`, - [run.missionId, projectId] - ); - if (mRow?.metadata_json) { - const meta = asRecord(JSON.parse(mRow.metadata_json)); - const mpc = asRecord(asRecord(meta?.launch)?.permissionConfig) as MissionPermissionConfig | undefined; - if (mpc) { - permissionConfig = mergeMissionPermissionConfig(permissionConfig, mpc); - } - } - } catch { /* non-critical */ } - } - const providers = normalizeMissionPermissions(permissionConfig); - return mapPermissionToInProcess(providers.opencode); - })(); - - try { - const aiResult = await aiIntegrationService.executeTask({ - feature: "orchestrator", - taskType, - prompt: promptPack.prompt, - cwd: resolvedCwd, - model: descriptor.id, - ...(reasoningEffort ? { reasoningEffort } : {}), - timeoutMs, - permissionMode: inProcessPermissionMode, - oneShot: true, - projectId, - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - }); - - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "worker_started", - reason: "in_process_worker_started", - detail: { - executorKind, - modelId: descriptor.id, - executionPath, - workerState: "running" - } - }); - - const summary = aiResult.text.trim().length > 0 - ? clipText(aiResult.text, 1_500) - : `Completed ${taskType} step with ${descriptor.displayName}.`; - return completeAndAdvance({ - attemptId: attempt.id, - status: "succeeded", - result: normalizeEnvelope({ - success: true, - summary, - outputs: { - text: aiResult.text, - structuredOutput: aiResult.structuredOutput ?? null, - modelId: descriptor.id, - executionPath, - }, - warnings: [], - sessionId: aiResult.sessionId, - trackedSession: false - }), - metadata: { - executorKind, - modelId: descriptor.id, - executionPath, - adapterState: "in_process_completed", - promptLength: promptPack.prompt.length, - steeringDirectiveCount: promptPack.steeringDirectiveCount, - } - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "worker_failed", - reason: "in_process_worker_failed", - detail: { - executorKind, - modelId: descriptor.id, - executionPath, - error: errorMessage - } - }); - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage, - metadata: { - executorKind, - modelId: descriptor.id, - executionPath, - adapterState: "in_process_failed", - } - }); - } - } - } - - const adapter = registeredAdapter ?? defaultAdapter; - if (adapter) { - // Build provider permission config: project-level → mission-level override. - // Both old (cli/inProcess) and new (providers) shapes are normalized through - // the same `normalizeMissionPermissions` pipeline. - const permissionConfig = (() => { - // 1. Start with project-level permission config (old shape from config file) - const snapshot = projectConfigService?.get(); - const ai = asRecord(snapshot?.effective?.ai); - const permissions = asRecord(ai?.permissions); - const projectPerms = projectPermissionConfigFromRecord(permissions); - - let mergedPerms = projectPerms; - - // 2. Layer mission-level overrides if present - if (run.missionId) { - try { - const missionRow = db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? and project_id = ? limit 1`, - [run.missionId, projectId] - ); - if (missionRow?.metadata_json) { - const meta = asRecord(JSON.parse(missionRow.metadata_json)); - const missionPermConfig = asRecord(asRecord(meta?.launch)?.permissionConfig) as MissionPermissionConfig | undefined; - if (missionPermConfig) { - mergedPerms = mergeMissionPermissionConfig(mergedPerms, missionPermConfig); - } - } - } catch { - // Non-critical: fall back to project-level permissions - } - } - - const providers = normalizeMissionPermissions(mergedPerms); - - // 3. Convert back to legacy shape for adapter compatibility, carrying _providers through - return providerPermissionsToLegacyConfig(providers); - })(); - - // Recovery-aware retry: read checkpoint and previous attempt data when retrying - const recoveryContext = await (async (): Promise<{ previousCheckpoint?: string; previousAttemptSummary?: string }> => { - if (attemptNumber <= 1) return {}; - const result: { previousCheckpoint?: string; previousAttemptSummary?: string } = {}; - - // 1. Read checkpoint file from the lane worktree if available, fall back to DB - 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) { - const checkpointPath = getWorkerCheckpointPath(worktreePath, step.stepKey); - try { - const content = fs.readFileSync(checkpointPath, "utf8"); - if (content.trim().length > 0) { - // Cap checkpoint content to avoid oversized prompts - result.previousCheckpoint = content.length > 12_000 - ? content.slice(0, 12_000) + "\n\n[checkpoint truncated]" - : content; - } - } catch { - // File does not exist or is unreadable — continue to DB fallback. - } - } - } - - // 1b. Fall back to DB-persisted checkpoint if filesystem read did not yield one - if (!result.previousCheckpoint) { - const dbCheckpoint = this.getWorkerCheckpoint({ missionId: run.missionId, stepKey: step.stepKey }); - if (dbCheckpoint && dbCheckpoint.content.trim().length > 0) { - result.previousCheckpoint = dbCheckpoint.content.length > 12_000 - ? dbCheckpoint.content.slice(0, 12_000) + "\n\n[checkpoint truncated]" - : dbCheckpoint.content; - } - } - - // 2. Gather summary from the most recent previous attempt on this step - const prevAttemptRow = db.get<{ result_envelope_json: string | null; error_message: string | null; status: string | null }>( - ` - select result_envelope_json, error_message, status - from orchestrator_attempts - where project_id = ? - and step_id = ? - and attempt_number = ? - limit 1 - `, - [projectId, step.id, attemptNumber - 1] - ); - if (prevAttemptRow) { - const parts: string[] = []; - const prevStatus = prevAttemptRow.status ?? "unknown"; - parts.push(`Previous attempt status: ${prevStatus}`); - if (prevAttemptRow.error_message) { - parts.push(`Error: ${prevAttemptRow.error_message}`); - } - if (prevAttemptRow.result_envelope_json) { - try { - const envelope = JSON.parse(prevAttemptRow.result_envelope_json) as Record<string, unknown>; - const summary = typeof envelope.summary === "string" ? envelope.summary.trim() : ""; - if (summary.length > 0) { - parts.push(`Summary: ${summary}`); - } - const warnings = Array.isArray(envelope.warnings) - ? (envelope.warnings as unknown[]).map((w) => String(w ?? "").trim()).filter(Boolean) - : []; - if (warnings.length > 0) { - parts.push(`Warnings: ${warnings.join("; ")}`); - } - } catch { - // malformed envelope — skip - } - } - if (parts.length > 0) { - result.previousAttemptSummary = parts.join("\n"); - } - } - - return result; - })(); - - const allSteps = listStepRows(run.id).map(toStep); - - // Resolve lane worktree path for CLI adapter prompt injection - const cliLaneWorktreePath = (() => { - if (!step.laneId) return null; - const row = db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? and project_id = ? limit 1`, - [step.laneId, projectId], - ); - const wt = typeof row?.worktree_path === "string" ? row.worktree_path.trim() : ""; - return wt.length > 0 ? wt : null; - })(); - - if (cliLaneWorktreePath) { - try { - fs.mkdirSync(path.join(cliLaneWorktreePath, ".ade", "plans"), { recursive: true }); - fs.mkdirSync(path.join(cliLaneWorktreePath, ".ade", "checkpoints"), { recursive: true }); - } catch { - // Directory creation is best-effort. Worker prompts still instruct - // the model to create the directories if needed. - } - } - - const stepForExecutor = (() => { - let s = step; - if (providerStepModelId && !(typeof step.metadata?.modelId === "string" && step.metadata.modelId.trim().length > 0)) { - s = { - ...s, - metadata: { - ...(s.metadata ?? {}), - modelId: providerStepModelId, - } - } satisfies OrchestratorStep; - } - if (cliLaneWorktreePath) { - s = { - ...s, - metadata: { - ...(s.metadata ?? {}), - laneWorktreePath: cliLaneWorktreePath, - } - } satisfies OrchestratorStep; - } - return s; - })(); - const result = await adapter.start({ - run, - step: stepForExecutor, - attempt, - allSteps, - contextProfile: contextPolicy, - laneExport: snapshot.laneExport, - projectExport: snapshot.projectExport, - docsRefs: snapshot.docsRefs, - fullDocs: snapshot.fullDocs, - createTrackedSession: (sessionArgs) => - this.createOrchestratedSession({ - ...sessionArgs, - tracked: true - }), - permissionConfig, - ...recoveryContext, - }); - if (result.status === "accepted") { - const sessionId = typeof result.sessionId === "string" ? result.sessionId.trim() : ""; - const managedLaunch = result.launch ?? null; - const attachedWorkerState = managedLaunch ? "initializing" : "working"; - if (sessionId) { - const sessionRow = db.get<{ transcript_path: string | null }>( - ` - select transcript_path - from terminal_sessions - where id = ? - limit 1 - `, - [sessionId] - ); - if (!sessionRow) { - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "executor_session_missing", - reason: "session_row_not_found", - detail: { - executorKind, - sessionId, - workerState: "error" - } - }); - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage: `Session row not found for accepted session: ${sessionId}` - }); - } - db.run( - ` - update orchestrator_attempts - set executor_session_id = ?, - metadata_json = ? - where id = ? - and project_id = ? - `, - [ - sessionId, - JSON.stringify({ - ...(attempt.metadata ?? {}), - ...(result.metadata ?? {}), - transcriptPath: sessionRow?.transcript_path ?? null, - workerState: attachedWorkerState, - workerSessionAttachedAt: nowIso() - }), - attempt.id, - projectId - ] - ); - emit({ type: "orchestrator-attempt-updated", runId: run.id, stepId: step.id, attemptId: attempt.id, reason: "session_attached" }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "executor_session_attached", - reason: "adapter_accepted", - detail: { - executorKind, - sessionId, - transcriptPath: sessionRow?.transcript_path ?? null, - workerState: attachedWorkerState - } - }); - } - - if (sessionId && managedLaunch && agentChatService) { - void (async () => { - try { - await agentChatService.runSessionTurn({ - sessionId, - text: managedLaunch.prompt, - displayText: managedLaunch.displayText, - // Mission worker timeouts are enforced by the orchestrator health - // sweep, which accounts for session heartbeats and recent output. - // The chat helper's wall-clock timeout cannot see active worker - // tool/file activity and can interrupt healthy Codex workers. - timeoutMs: null, - ...(managedLaunch.reasoningEffort ? { reasoningEffort: managedLaunch.reasoningEffort } : {}), - ...(managedLaunch.executionMode ? { executionMode: managedLaunch.executionMode } : {}), - }); - const currentAttemptRow = getAttemptRow(attempt.id); - if (!currentAttemptRow || currentAttemptRow.status !== "running") return; - const currentStep = getStepRow(step.id); - let currentStepMetadata = asRecord(toStep(currentStep ?? stepRow).metadata) ?? {}; - const attemptMetadata = parseJsonRecord(currentAttemptRow.metadata_json); - const transcriptPath = - typeof attemptMetadata?.transcriptPath === "string" - ? attemptMetadata.transcriptPath.trim() - : ""; - const transcriptAnalysis = analyzeTranscriptFromPath(transcriptPath); - let hasStructuredResult = asRecord(currentStepMetadata.lastResultReport) != null; - if (!hasStructuredResult) { - const recoveredPayload = extractReportResultPayloadFromTranscript(transcriptPath); - const recoveredReport = recoveredPayload - ? normalizeRecoveredWorkerResultReport({ - payload: recoveredPayload, - step: toStep(currentStep ?? stepRow), - attempt: toAttempt(currentAttemptRow), - missionId: run.missionId, - runId: run.id, - }) - : null; - if (recoveredReport) { - currentStepMetadata = { - ...currentStepMetadata, - lastResultReport: recoveredReport, - recoveredResultReportFromTranscript: true, - }; - this.updateStepMetadata({ - runId: run.id, - stepId: step.id, - metadata: currentStepMetadata, - allowTerminal: true, - }); - this.appendRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId, - eventType: "worker_result_report", - payload: { - ...recoveredReport, - recoveredFromTranscript: true, - } as unknown as Record<string, unknown>, - }); - this.appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "worker_result_reported", - reason: "transcript_report_result", - detail: { - ...recoveredReport, - recoveredFromTranscript: true, - } as unknown as Record<string, unknown>, - }); - this.createHandoff({ - missionId: run.missionId, - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - handoffType: "worker_result_report", - producer: step.stepKey, - payload: recoveredReport as unknown as Record<string, unknown>, - }); - hasStructuredResult = true; - } - } - const silentFailure = hasStructuredResult - ? null - : classifySilentWorkerExit({ - stepMetadata: currentStepMetadata, - transcriptSummary: transcriptAnalysis.summary, - hasMaterialOutput: transcriptAnalysis.hasMaterialOutput, - hasLifecycleActivity: transcriptAnalysis.hasLifecycleActivity, - appearsInterruptedDuringCommand: transcriptAnalysis.appearsInterruptedDuringCommand, - }); - if (silentFailure) { - await this.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: silentFailure.errorClass, - errorMessage: silentFailure.errorMessage, - }); - } else { - await this.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - } - } catch (error) { - const currentAttemptRow = getAttemptRow(attempt.id); - if (!currentAttemptRow || currentAttemptRow.status !== "running") return; - const currentStep = getStepRow(step.id); - const currentStepMetadata = asRecord(toStep(currentStep ?? stepRow).metadata) ?? {}; - const attemptMetadata = parseJsonRecord(currentAttemptRow.metadata_json); - const transcriptPath = - typeof attemptMetadata?.transcriptPath === "string" - ? attemptMetadata.transcriptPath.trim() - : ""; - const transcriptAnalysis = analyzeTranscriptFromPath(transcriptPath); - const errorText = error instanceof Error ? error.message : String(error); - const silentFailure = classifySilentWorkerExit({ - stepMetadata: currentStepMetadata, - transcriptSummary: transcriptAnalysis.summary, - hasMaterialOutput: transcriptAnalysis.hasMaterialOutput, - hasLifecycleActivity: transcriptAnalysis.hasLifecycleActivity, - appearsInterruptedDuringCommand: transcriptAnalysis.appearsInterruptedDuringCommand, - }); - await this.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: silentFailure?.errorClass ?? "executor_failure", - errorMessage: silentFailure?.errorMessage ?? errorText, - }); - } finally { - try { - await agentChatService.dispose({ sessionId }); - } catch { - // Ignore disposal failures — tracked session reconciliation is best-effort here. - } - } - })(); - } - - // ── Critical fix: trigger autopilot pass for OTHER ready steps ── - // The "accepted" status means this step is now running (not complete), - // so we must NOT call completeAndAdvance. But we DO need to kick off - // a new autopilot cycle so sibling steps that are ready can start. - const acceptedAttemptRunId = run.id; - const acceptedAttemptId = attempt.id; - const acceptedSessionId = sessionId; - // 200ms delay allows the current autopilot pass to finish and release - // `autopilotRunLocks`. The lock guards against re-entrance: if the - // current pass hasn't released yet, the deferred call returns 0 - // immediately (reason "accepted_step_advance" does NOT wait on the - // lock, unlike "initial_ramp_up"). 200ms (up from 50ms) ensures the - // initial pass has time to complete even when spawning multiple PTY - // sessions, so the deferred call reliably picks up remaining siblings. - setTimeout(() => { - void this.startReadyAutopilotAttempts({ - runId: acceptedAttemptRunId, - reason: "accepted_step_advance" - }).catch(() => {}); - }, 200); - - // ── Startup verification watchdog ── - // After a short grace period, verify the accepted session has produced output. - // If not, emit a warning event so the coordinator/health sweep can act. - // Quiet read-only planning workers are excluded here because some providers - // produce little or no PTY output until they finish, and that silence was - // creating false "stalled" signals even while the worker was legitimately - // thinking. Long-running health sweeps still cover genuinely wedged sessions. - const stepMeta = asRecord(step.metadata) ?? {}; - const stepPhaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; - const skipStartupVerification = - stepMeta.readOnlyExecution === true - || stepPhaseKey === "planning" - || stepType === "planning"; - if (acceptedSessionId && !skipStartupVerification) { - setTimeout(() => { - try { - const verifyRow = db.get<{ last_output_at: string | null; status: string | null }>( - ` - select last_output_at, status - from terminal_sessions - where id = ? - limit 1 - `, - [acceptedSessionId] - ); - const hasOutput = Boolean(verifyRow?.last_output_at); - const sessionStatus = verifyRow?.status ?? "unknown"; - if (!hasOutput && sessionStatus !== "completed" && sessionStatus !== "exited") { - appendTimelineEvent({ - runId: acceptedAttemptRunId, - stepId: step.id, - attemptId: acceptedAttemptId, - eventType: "startup_verification_warning", - reason: "no_output_after_startup", - detail: { - executorKind, - sessionId: acceptedSessionId, - sessionStatus, - elapsedMs: STARTUP_VERIFICATION_GRACE_MS, - workerState: "stalled" - } - }); - emit({ - type: "orchestrator-attempt-updated", - runId: acceptedAttemptRunId, - stepId: step.id, - attemptId: acceptedAttemptId, - reason: "startup_verification_warning" - }); - } - } catch { - // Ignore verification errors — health sweep will catch persistent issues - } - }, STARTUP_VERIFICATION_GRACE_MS); - } - - return toAttempt(getAttemptRow(attempt.id) ?? attemptRow); - } - if (result.status === "completed") { - return completeAndAdvance({ - attemptId: attempt.id, - status: "succeeded", - result: result.result - }); - } - return completeAndAdvance({ - attemptId: attempt.id, - status: "failed", - errorClass: result.errorClass ?? "executor_failure", - errorMessage: result.errorMessage, - metadata: result.metadata ?? null - }); - } - - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "executor_adapter_missing", - reason: "manual_wait", - detail: { - executorKind - } - }); - this.tick({ runId: run.id }); - return attempt; - }, - - async completeAttempt(args: { - attemptId: string; - status: Extract<OrchestratorAttemptStatus, "succeeded" | "failed" | "blocked" | "canceled">; - result?: OrchestratorAttemptResultEnvelope; - errorClass?: OrchestratorErrorClass; - errorMessage?: string | null; - retryBackoffMs?: number; - metadata?: Record<string, unknown> | null; - }): Promise<OrchestratorAttempt> { - const attemptRow = getAttemptRow(args.attemptId); - if (!attemptRow) throw new Error(`Attempt not found: ${args.attemptId}`); - const stepRow = getStepRow(attemptRow.step_id); - if (!stepRow) throw new Error(`Step not found for attempt: ${args.attemptId}`); - const runRow = getRunRow(attemptRow.run_id); - if (!runRow) throw new Error(`Run not found for attempt: ${args.attemptId}`); - const attempt = toAttempt(attemptRow); - const step = toStep(stepRow); - const run = toRun(runRow); - - const completedAt = nowIso(); - const runtimeConfig = getRuntimeConfig(); - let status = args.status; - const fileReservationCheck = - status === "succeeded" - ? await evaluateFileReservationViolations({ - step, - result: args.result ?? null, - metadata: args.metadata ?? null - }) - : null; - const fileReservationMessage = (() => { - if (!fileReservationCheck || fileReservationCheck.violations.length === 0) return null; - const preview = fileReservationCheck.violations.slice(0, 4).join(", "); - const suffix = fileReservationCheck.violations.length > 4 ? ` (+${fileReservationCheck.violations.length - 4} more)` : ""; - return `File reservation violation: modified files outside claimed scope (${preview}${suffix}).`; - })(); - const reservationGuardMode = runtimeConfig.fileReservationGuardMode; - const reservationBlocks = status === "succeeded" && Boolean(fileReservationMessage) && reservationGuardMode === "block"; - const reservationWarns = status === "succeeded" && Boolean(fileReservationMessage) && reservationGuardMode === "warn"; - if (reservationBlocks) { - status = "blocked"; - } - let stepMetadata = asRecord(step.metadata) ?? {}; - const phaseKey = typeof stepMetadata.phaseKey === "string" ? stepMetadata.phaseKey.trim().toLowerCase() : ""; - const stepType = typeof stepMetadata.stepType === "string" ? stepMetadata.stepType.trim().toLowerCase() : ""; - const planningStep = - phaseKey === "planning" || - stepType === "planning"; - const effectiveAttemptMetadata = { - ...(asRecord(attempt.metadata) ?? {}), - ...(args.metadata ?? {}), - }; - const transcriptPath = - typeof effectiveAttemptMetadata.transcriptPath === "string" ? effectiveAttemptMetadata.transcriptPath.trim() : ""; - const sessionId = - typeof effectiveAttemptMetadata.trackedSessionId === "string" && effectiveAttemptMetadata.trackedSessionId.trim().length > 0 - ? effectiveAttemptMetadata.trackedSessionId.trim() - : null; - let lastResultReport = asRecord(stepMetadata.lastResultReport); - if (!lastResultReport && status === "succeeded") { - 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 = extractReportResultPayloadFromWorkerStepOutput({ - worktreePath, - stepKey: step.stepKey, - attemptId: attempt.id, - }); - if (recoveredPayload) recoveredFrom = "step_output"; - } - } - const recoveredReport = recoveredPayload - ? normalizeRecoveredWorkerResultReport({ - payload: recoveredPayload, - step, - attempt, - missionId: run.missionId, - runId: 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: run.id, - stepId: step.id, - metadata: stepMetadata, - allowTerminal: true, - }); - this.appendRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId, - eventType: "worker_result_report", - payload: { - ...recoveredReport, - recoveredFromTranscript, - recoveredFromStepOutput, - } as unknown as Record<string, unknown>, - }); - this.appendTimelineEvent({ - runId: run.id, - stepId: 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<string, unknown>, - }); - this.createHandoff({ - missionId: run.missionId, - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - handoffType: "worker_result_report", - producer: step.stepKey, - payload: recoveredReport as unknown as Record<string, unknown>, - }); - lastResultReport = asRecord(stepMetadata.lastResultReport); - } - } - const reportedPlan = asRecord(lastResultReport?.plan); - const reportedPlanMarkdown = - typeof reportedPlan?.markdown === "string" ? reportedPlan.markdown.trim() : ""; - const expectedPlanPath = - typeof reportedPlan?.artifactPath === "string" && reportedPlan.artifactPath.trim().length > 0 - ? reportedPlan.artifactPath.trim() - : ".ade/plans/mission-plan.md"; - const phaseCardsForQuestionRecovery = resolveRunPhaseCardsFromMetadata(asRecord(run.metadata)) ?? []; - const phaseForQuestionRecovery = - phaseCardsForQuestionRecovery.find((phase) => phase.phaseKey.trim().toLowerCase() === phaseKey) - ?? phaseCardsForQuestionRecovery.find((phase) => phase.name.trim().toLowerCase() === String(stepMetadata.phaseName ?? "").trim().toLowerCase()) - ?? null; - const phaseAllowsQuestions = phaseForQuestionRecovery?.askQuestions?.enabled === true; - const missionPromptRow = db.get<{ prompt: string | null }>( - `select prompt from missions where id = ? and project_id = ? limit 1`, - [run.missionId, projectId], - ); - const planningQuestionRequiredBeforeExit = phaseRequiresPlanningQuestionBeforeExit({ - phase: phaseForQuestionRecovery, - missionPrompt: missionPromptRow?.prompt ?? null, - }); - const stepPhaseAskQuestions = asRecord(stepMetadata.phaseAskQuestions); - const normalizedPlanningQuestionRequiredBeforeExit = - typeof stepPhaseAskQuestions?.requiredBeforeExit === "boolean" - ? stepPhaseAskQuestions.requiredBeforeExit - : planningQuestionRequiredBeforeExit; - const planningQuestionRows = normalizedPlanningQuestionRequiredBeforeExit - ? db.all<{ status: string; intervention_type: string; metadata_json: string | null }>( - ` - select status, intervention_type, metadata_json - from mission_interventions - where project_id = ? - and mission_id = ? - and intervention_type = 'manual_input' - `, - [projectId, run.missionId], - ) - : []; - let hasResolvedRequiredPlanningQuestion = hasResolvedPlanningQuestionIntervention( - planningQuestionRows.map((row) => ({ - status: row.status, - interventionType: row.intervention_type, - metadata: parseJsonRecord(row.metadata_json), - })), - { runId: run.id }, - ); - if ( - status === "succeeded" - && planningStep - && normalizedPlanningQuestionRequiredBeforeExit - && !hasResolvedRequiredPlanningQuestion - ) { - const resolvedChatQuestion = readResolvedPlannerChatQuestion({ sessionId, transcriptPath }); - if (resolvedChatQuestion) { - ensureResolvedPlannerChatQuestionIntervention({ - missionId: run.missionId, - runId: run.id, - step, - attempt, - sessionId, - phaseKey, - stepType, - itemId: resolvedChatQuestion.itemId, - question: resolvedChatQuestion.question, - requestedAt: resolvedChatQuestion.requestedAt, - resolvedAt: resolvedChatQuestion.resolvedAt, - resolutionKind: resolvedChatQuestion.resolutionKind, - }); - hasResolvedRequiredPlanningQuestion = true; - } - } - const naturalPlannerQuestion = - status === "succeeded" && planningStep && reportedPlanMarkdown.length === 0 && phaseAllowsQuestions - ? extractNaturalQuestionFromTranscriptPath(transcriptPath) - : null; - const requiredPlanningQuestion = - status === "succeeded" - && planningStep - && reportedPlanMarkdown.length > 0 - && normalizedPlanningQuestionRequiredBeforeExit - && !hasResolvedRequiredPlanningQuestion - ? "What blocking requirements, acceptance criteria, scope decisions, or constraints should the planner account for before implementation starts?" - : null; - const openPlannerQuestion = (args: { - question: string; - reasonCode: PlannerQuestionReasonCode; - requestedAction: string; - pauseReason: string; - timelineSource: string; - }) => { - const questionLink = buildQuestionThreadLink({ - attemptId: attempt.id, - preview: args.question, - occurredAt: completedAt, - }); - const interventionId = ensurePlannerQuestionIntervention({ - missionId: run.missionId, - runId: run.id, - step, - attempt, - question: args.question, - questionLink, - detectedAt: completedAt, - sessionId, - phaseKey, - stepType, - reasonCode: args.reasonCode, - source: args.reasonCode, - requestedAction: args.requestedAction, - pauseReason: args.pauseReason, - }); - status = "blocked"; - stepMetadata = { - ...stepMetadata, - awaitingUserInput: { - source: args.reasonCode, - question: args.question, - interventionId, - questionLink, - detectedAt: completedAt, - }, - }; - this.updateStepMetadata({ - runId: run.id, - stepId: step.id, - metadata: stepMetadata, - allowTerminal: true, - }); - this.appendRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId, - eventType: "question", - eventKey: questionLink.messageId, - payload: { - interventionId, - preview: args.question, - runtimeState: "waiting-input", - source: args.reasonCode, - threadId: questionLink.threadId, - messageId: questionLink.messageId, - replyTo: questionLink.replyTo, - }, - }); - this.appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "intervention_opened", - reason: args.reasonCode, - detail: { - interventionId, - question: args.question, - source: args.timelineSource, - }, - }); - }; - if (naturalPlannerQuestion) { - openPlannerQuestion({ - question: naturalPlannerQuestion, - reasonCode: "planner_natural_question", - requestedAction: "Answer the planning question to retry the planner with your guidance.", - pauseReason: "Planning question needs an answer", - timelineSource: "transcript_question_recovery", - }); - } - if (requiredPlanningQuestion) { - openPlannerQuestion({ - question: requiredPlanningQuestion, - reasonCode: "planner_required_question_missing", - requestedAction: "Answer the required planning question so ADE can retry planning with your guidance.", - pauseReason: "Required planning question needs an answer", - timelineSource: "planning_question_policy", - }); - } - const plannerContractViolation = - status === "succeeded" && - planningStep && - reportedPlanMarkdown.length === 0; - const reportedSummary = - typeof lastResultReport?.summary === "string" ? lastResultReport.summary.trim() : ""; - const transcriptSummary = - !args.result && status === "succeeded" - ? deriveTranscriptSummaryFromPath(transcriptPath) - : null; - // Detect blocking soft-failures: attempt says "succeeded" but warnings - // contain sandbox blocks, permission denials, tool startup failures, etc. - // Use the same derived summary path that transcript-only completions rely on, - // so planner/worker sessions cannot silently succeed with a blocked write summary. - let softFailureOverride: ReturnType<typeof classifyBlockingWarnings> | null = null; - if (status === "succeeded") { - const warningsToCheck = Array.isArray(args.result?.warnings) ? args.result.warnings : []; - const explicitSummary = - typeof args.result?.summary === "string" ? args.result.summary.trim() : ""; - const summaryToCheck = - explicitSummary - || reportedSummary - || transcriptSummary - || null; - softFailureOverride = classifyBlockingWarnings({ warnings: warningsToCheck, summary: summaryToCheck }); - if (softFailureOverride.hasBlockingFailure) { - status = "failed"; - } else { - softFailureOverride = null; - } - } - if (plannerContractViolation) { - status = "failed"; - } - const effectiveErrorMessage = plannerContractViolation - ? "PLANNER CONTRACT VIOLATION: Planning worker completed without returning report_result.plan.markdown." - : naturalPlannerQuestion - ? `Planning worker is waiting for input: ${naturalPlannerQuestion}` - : requiredPlanningQuestion - ? `Planning worker skipped a required blocking question: ${requiredPlanningQuestion}` - : softFailureOverride - ? softFailureOverride.detail - : reservationBlocks - ? fileReservationMessage - : args.errorMessage ?? null; - const errorClass = - plannerContractViolation - ? "planner_contract_violation" as OrchestratorErrorClass - : naturalPlannerQuestion - ? "deterministic" as OrchestratorErrorClass - : requiredPlanningQuestion - ? "deterministic" as OrchestratorErrorClass - : softFailureOverride - ? "soft_success_blocking_failure" as OrchestratorErrorClass - : status === "failed" - ? args.errorClass ?? "executor_failure" - : status === "blocked" - ? args.errorClass ?? (reservationBlocks ? "policy" : "policy") - : status === "canceled" - ? "canceled" - : "none"; - if (plannerContractViolation) { - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - eventType: "planning_artifact_missing", - reason: "planner_plan_missing", - detail: { - expectedPlanPath, - phaseKey: phaseKey || null, - stepType: stepType || null, - } - }); - this.appendRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - sessionId: attempt.executorSessionId ?? null, - eventType: "planning_artifact_missing", - eventKey: `planning_artifact_missing:${attempt.id}`, - payload: { - expectedPlanPath, - phaseKey: phaseKey || null, - stepType: stepType || null, - reason: "planner_plan_missing", - } - }); - } - const restartInterruptedFailure = - status === "failed" && - isRestartInterruptedAttemptFailure({ errorClass, errorMessage: effectiveErrorMessage }); - const retryable = status === "failed" ? RETRYABLE_ERROR_CLASSES.has(errorClass) : false; - const retryRemaining = status === "failed" ? step.retryCount < step.retryLimit : false; - const shouldRetry = status === "failed" ? restartInterruptedFailure || (retryable && retryRemaining) : false; - const retryConsumesBudget = shouldRetry && !restartInterruptedFailure; - const aiRetryBackoffRaw = Number( - step.metadata?.aiRetryBackoffMs ?? step.metadata?.ai_retry_backoff_ms ?? Number.NaN - ); - const aiRetryBackoffMs = - Number.isFinite(aiRetryBackoffRaw) && aiRetryBackoffRaw >= 0 - ? Math.min(10 * 60_000, Math.floor(aiRetryBackoffRaw)) - : null; - // Exponential backoff with priority: caller backoff > AI-suggested > exponential default. - const exponentialBackoffMs = Math.min( - RETRY_MAX_MS, - Math.floor(RETRY_BASE_MS * Math.pow(RETRY_MULTIPLIER, step.retryCount)) - ); - const callerBackoffRaw = Number(args.retryBackoffMs ?? Number.NaN); - const callerBackoffMs = - Number.isFinite(callerBackoffRaw) && callerBackoffRaw >= 0 - ? Math.min(RETRY_MAX_MS, Math.floor(callerBackoffRaw)) - : null; - const computedBackoff = shouldRetry - ? restartInterruptedFailure - ? 0 - : (callerBackoffMs ?? aiRetryBackoffMs ?? exponentialBackoffMs) - : 0; - const reportedFilesChanged = Array.isArray(lastResultReport?.filesChanged) - ? lastResultReport.filesChanged - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0) - : []; - const reportedTests = asRecord(lastResultReport?.testsRun); - const implicitOutputs = (() => { - if (status !== "succeeded") return null; - const outputs: Record<string, unknown> = {}; - if (typeof reportedPlan?.markdown === "string" && reportedPlan.markdown.trim().length > 0) { - outputs.planMarkdown = reportedPlan.markdown.trim(); - outputs.planPath = - typeof reportedPlan.artifactPath === "string" && reportedPlan.artifactPath.trim().length > 0 - ? reportedPlan.artifactPath.trim() - : ".ade/plans/mission-plan.md"; - if (typeof reportedPlan.summary === "string" && reportedPlan.summary.trim().length > 0) { - outputs.planSummary = reportedPlan.summary.trim(); - } - } - if (reportedFilesChanged.length > 0) { - outputs.filesChanged = reportedFilesChanged; - } - if (reportedTests) { - if (typeof reportedTests.command === "string" && reportedTests.command.trim().length > 0) { - outputs.testsCommand = reportedTests.command.trim(); - } - const passed = Number(reportedTests.passed); - if (Number.isFinite(passed)) outputs.testsPassed = Math.max(0, Math.floor(passed)); - const failed = Number(reportedTests.failed); - if (Number.isFinite(failed)) outputs.testsFailed = Math.max(0, Math.floor(failed)); - const skipped = Number(reportedTests.skipped); - if (Number.isFinite(skipped)) outputs.testsSkipped = Math.max(0, Math.floor(skipped)); - if (typeof reportedTests.raw === "string" && reportedTests.raw.trim().length > 0) { - outputs.testsSummary = reportedTests.raw.trim(); - } - } - return Object.keys(outputs).length > 0 ? outputs : null; - })(); - const defaultSummary = - status === "succeeded" - ? reportedSummary - || transcriptSummary - || ( - stepMetadata.readOnlyExecution === true - || String(stepMetadata.stepType ?? "").trim().toLowerCase() === "planning" - ? "Planning session completed." - : "Step completed." - ) - : status === "failed" - ? effectiveErrorMessage?.trim() || "Step attempt failed." - : status === "blocked" - ? effectiveErrorMessage?.trim() || "Step attempt blocked." - : "Step attempt canceled."; - const envelope: OrchestratorAttemptResultEnvelope = normalizeEnvelope( - args.result ?? { - success: status === "succeeded", - summary: defaultSummary, - outputs: implicitOutputs, - warnings: status === "failed" || status === "blocked" ? [defaultSummary] : [], - sessionId: attemptRow.executor_session_id, - trackedSession: true - } - ); - if (reservationWarns && fileReservationMessage && !envelope.warnings.includes(fileReservationMessage)) { - envelope.warnings.push(fileReservationMessage); - } - const workerState = status === "succeeded" ? "idle" : "disposed"; - let validationContractUnfulfilledSignal = false; - let validationSelfCheckReminder = false; - - db.run( - ` - update orchestrator_attempts - set status = ?, - error_class = ?, - error_message = ?, - retry_backoff_ms = ?, - result_envelope_json = ?, - metadata_json = ?, - completed_at = ?, - started_at = coalesce(started_at, ?) - where id = ? - and project_id = ? - `, - [ - status, - errorClass, - effectiveErrorMessage, - computedBackoff, - JSON.stringify(envelope), - JSON.stringify({ - ...(parseJsonRecord(attemptRow.metadata_json) ?? {}), - ...(args.metadata ?? {}), - ...(fileReservationCheck - ? { - fileReservationGuardMode: reservationGuardMode, - fileReservationScopes: fileReservationCheck.normalizedScopes, - fileReservationTouchedPaths: fileReservationCheck.touchedPaths, - fileReservationViolations: fileReservationCheck.violations - } - : {}), - ...(softFailureOverride - ? { - softFailureOverride: { - category: softFailureOverride.category, - detail: softFailureOverride.detail, - originalStatus: 'succeeded', - overriddenAt: completedAt, - } - } - : {}), - workerState, - workerCompletedAt: completedAt - }), - completedAt, - completedAt, - args.attemptId, - projectId - ] - ); - - releaseClaimsForAttempt({ - attemptId: args.attemptId, - state: status === "failed" ? "released" : "released" - }); - - // Clean up temporary worker runtime files for this worker. - const laneWorktreeRow = step.laneId - ? db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? and project_id = ? limit 1`, - [step.laneId, projectId] - ) - : null; - const laneWorktreePath = typeof laneWorktreeRow?.worktree_path === "string" && laneWorktreeRow.worktree_path.trim().length > 0 - ? laneWorktreeRow.worktree_path.trim() - : null; - cleanupWorkerRuntimeFiles(projectRoot, args.attemptId, laneWorktreePath); - - // Sync worker checkpoint file to DB for all completion states - if (step.laneId) { - const ckLaneRow = db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? and project_id = ? limit 1`, - [step.laneId, projectId] - ); - const ckWorktreePath = typeof ckLaneRow?.worktree_path === "string" && ckLaneRow.worktree_path.trim().length - ? ckLaneRow.worktree_path.trim() - : null; - if (ckWorktreePath) { - const ckPath = getWorkerCheckpointPath(ckWorktreePath, step.stepKey); - try { - const ckContent = fs.readFileSync(ckPath, "utf8"); - if (ckContent.trim().length > 0) { - this.upsertWorkerCheckpoint({ - missionId: run.missionId, - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - stepKey: step.stepKey, - content: ckContent, - filePath: ckPath - }); - } - } catch { - // File may not exist — expected when no checkpoint was written. - } - } - } - - if (status === "succeeded") { - db.run( - ` - update orchestrator_steps - set status = 'succeeded', - updated_at = ?, - completed_at = ?, - last_attempt_id = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [completedAt, completedAt, args.attemptId, step.id, run.id, projectId] - ); - - // Clean up checkpoint file on success (DB sync already done above) - 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) { - const checkpointPath = getWorkerCheckpointPath(worktreePath, step.stepKey); - try { - fs.unlinkSync(checkpointPath); - } catch { - // File may not exist — expected when no checkpoint was written. - } - } - } - - const latestStepRow = getStepRow(step.id); - const latestStep = latestStepRow ? toStep(latestStepRow) : step; - const latestStepMeta = asRecord(latestStep.metadata) ?? {}; - const isAutoSpawnedValidationStep = latestStepMeta.autoSpawnedValidation === true; - if (!isAutoSpawnedValidationStep) { - const runMetadata = asRecord(run.metadata); - const phaseCards = resolveRunPhaseCardsFromMetadata(runMetadata) ?? []; - const stepPhaseKey = typeof latestStepMeta.phaseKey === "string" ? latestStepMeta.phaseKey.trim() : ""; - const stepPhaseName = typeof latestStepMeta.phaseName === "string" ? latestStepMeta.phaseName.trim() : ""; - const phaseCard = - phaseCards.find((phase) => phase.phaseKey === stepPhaseKey) - ?? phaseCards.find((phase) => phase.name === stepPhaseName) - ?? null; - const validationPhaseCard = - phaseCards.find((phase) => phase.phaseKey.trim().toLowerCase() === "validation") - ?? phaseCards.find((phase) => phase.name.trim().toLowerCase() === "validation") - ?? null; - const phaseTier = normalizeValidationTier(phaseCard?.validationGate?.tier); - const phaseRequiresValidation = phaseCard?.validationGate?.required === true && phaseTier !== "none"; - const existingContract = parseValidationContract(latestStepMeta.validationContract ?? null); - const requiresValidation = existingContract?.required === true || phaseRequiresValidation; - if (requiresValidation) { - const normalizedContract: NormalizedValidationContract = { - level: existingContract?.level ?? "step", - tier: existingContract?.tier ?? (phaseTier === "dedicated" ? "dedicated" : "self"), - required: true, - criteria: - existingContract?.criteria - ?? (typeof phaseCard?.validationGate?.criteria === "string" && phaseCard.validationGate.criteria.trim().length > 0 - ? phaseCard.validationGate.criteria.trim() - : `Validate completed output for step "${latestStep.stepKey}".`), - evidence: existingContract?.evidence ?? [], - maxRetries: existingContract?.maxRetries ?? 2, - }; - const validationStateRaw = typeof latestStepMeta.validationState === "string" - ? latestStepMeta.validationState.trim().toLowerCase() - : ""; - const nextValidationState = - validationStateRaw === "pass" || validationStateRaw === "passed" || validationStateRaw === "success" || validationStateRaw === "succeeded" - ? "pass" - : validationStateRaw === "fail" || validationStateRaw === "failed" || validationStateRaw === "failure" - ? "fail" - : "pending"; - const nextStepMetadata = { - ...latestStepMeta, - validationContract: normalizedContract, - validationState: nextValidationState, - }; - db.run( - ` - update orchestrator_steps - set metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(nextStepMetadata), completedAt, latestStep.id, run.id, projectId] - ); - - if (!hasPassingValidation(nextStepMetadata)) { - const detail = `Step "${latestStep.stepKey}" completed without required validation pass (${normalizedContract.tier} tier).`; - appendTimelineEvent({ - runId: run.id, - stepId: latestStep.id, - attemptId: args.attemptId, - eventType: "validation_contract_unfulfilled", - reason: "required_validation_missing", - detail: { - stepKey: latestStep.stepKey, - phaseKey: stepPhaseKey || null, - phaseName: stepPhaseName || null, - tier: normalizedContract.tier, - criteria: normalizedContract.criteria, - autoSpawnedValidation: normalizedContract.tier === "dedicated" - } - }); - persistRuntimeEvent({ - runId: run.id, - stepId: latestStep.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "validation_contract_unfulfilled", - eventKey: `validation_contract_unfulfilled:${run.id}:${latestStep.id}:${args.attemptId}:${completedAt}`, - occurredAt: completedAt, - payload: { - stepId: latestStep.id, - stepKey: latestStep.stepKey, - phaseKey: stepPhaseKey || null, - phaseName: stepPhaseName || null, - tier: normalizedContract.tier, - required: true, - detail - } - }); - emit({ - type: "orchestrator-step-updated", - runId: run.id, - stepId: latestStep.id, - reason: "validation_contract_unfulfilled" - }); - validationContractUnfulfilledSignal = true; - - if (normalizedContract.tier === "dedicated") { - const existingSteps = listStepRows(run.id).map(toStep); - const existingValidator = existingSteps.find((candidate) => { - const candidateMeta = asRecord(candidate.metadata); - const targetStepId = typeof candidateMeta?.targetStepId === "string" ? candidateMeta.targetStepId.trim() : ""; - return candidateMeta?.autoSpawnedValidation === true && targetStepId === latestStep.id; - }); - - if (!existingValidator) { - const templateRoles = Array.isArray(asRecord(asRecord(runMetadata?.teamRuntime)?.template)?.roles) - ? (asRecord(asRecord(runMetadata?.teamRuntime)?.template)?.roles as Array<Record<string, unknown>>) - : []; - const validatorRole = - templateRoles.find((role) => { - const roleName = typeof role.name === "string" ? role.name.trim().toLowerCase() : ""; - if (roleName.includes("validator")) return true; - const capabilities = Array.isArray(role.capabilities) - ? role.capabilities.map((entry) => String(entry ?? "").trim().toLowerCase()) - : []; - return capabilities.some((entry) => entry.includes("validation") || entry.includes("review")); - }) ?? null; - const validatorRoleName = - typeof validatorRole?.name === "string" && validatorRole.name.trim().length > 0 - ? validatorRole.name.trim() - : "validator"; - - const validatorPhaseModel = asRecord(validationPhaseCard?.model); - const phaseModel = asRecord(latestStepMeta.phaseModel); - const phaseModelId = typeof validatorPhaseModel?.modelId === "string" - ? validatorPhaseModel.modelId.trim() - : typeof phaseModel?.modelId === "string" - ? phaseModel.modelId.trim() - : ""; - const runtimePhaseModel = asRecord(asRecord(runMetadata?.phaseRuntime)?.currentPhaseModel); - const runtimePhaseModelId = typeof runtimePhaseModel?.modelId === "string" ? runtimePhaseModel.modelId.trim() : ""; - const candidateModelIds = [ - phaseModelId, - runtimePhaseModelId, - ].filter((entry) => entry.length > 0); - const validatorModelId = candidateModelIds.find((entry) => Boolean(resolveModelDescriptor(entry))) ?? ""; - if (!validatorModelId.length) { - appendTimelineEvent({ - runId: run.id, - stepId: latestStep.id, - attemptId: args.attemptId, - eventType: "validation_auto_spawned", - reason: "phase_model_missing", - detail: { - targetStepId: latestStep.id, - targetStepKey: latestStep.stepKey, - phaseModelId: phaseModelId || null, - runtimePhaseModelId: runtimePhaseModelId || null, - message: "Dedicated validator could not be auto-spawned because the active phase model is missing or unregistered." - } - }); - emit({ - type: "orchestrator-step-updated", - runId: run.id, - stepId: latestStep.id, - reason: "validation_auto_spawned" - }); - } else { - const outputs = asRecord(envelope.outputs); - const collectStringList = (value: unknown): string[] => { - if (!Array.isArray(value)) return []; - return value - .map((entry) => String(entry ?? "").trim()) - .filter((entry) => entry.length > 0); - }; - const filesChanged = [ - ...collectStringList(outputs?.filesChanged), - ...collectStringList(outputs?.changedFiles), - ...collectStringList(outputs?.modifiedFiles), - ...collectStringList(outputs?.files_changed), - ...collectStringList(outputs?.changed_files), - ...collectStringList(outputs?.modified_files), - ].filter((entry, index, all) => all.indexOf(entry) === index); - const testsRun = asRecord(outputs?.testsRun ?? outputs?.tests_run); - const testsSummary = testsRun - ? [ - `passed=${Number(testsRun.passed ?? 0)}`, - `failed=${Number(testsRun.failed ?? 0)}`, - `skipped=${Number(testsRun.skipped ?? 0)}` - ].join(", ") - : "not reported"; - const validatorPrompt = [ - `Validate completed worker output for step "${latestStep.stepKey}" (${latestStep.title}).`, - `Validation criteria: ${normalizedContract.criteria}`, - `Worker summary: ${envelope.summary}`, - filesChanged.length > 0 ? `Files changed:\n- ${filesChanged.join("\n- ")}` : "Files changed: none reported.", - `Tests summary: ${testsSummary}`, - `Use report_validation with targetWorkerId="${latestStep.stepKey}" and verdict "pass" or "fail".`, - "When failing, include findings with concrete remediation steps." - ].join("\n\n"); - - const existingStepKeys = new Set(existingSteps.map((candidate) => candidate.stepKey)); - const baseValidatorKey = `validate_${latestStep.stepKey}`.replace(/[^a-zA-Z0-9_-]/g, "_"); - let validatorStepKey = baseValidatorKey; - let keyCounter = 1; - while (existingStepKeys.has(validatorStepKey)) { - validatorStepKey = `${baseValidatorKey}_${keyCounter}`; - keyCounter += 1; - } - const maxStepIndex = existingSteps.reduce((max, candidate) => Math.max(max, candidate.stepIndex), -1); - const spawned = this.addSteps({ - runId: run.id, - steps: [ - { - stepKey: validatorStepKey, - title: `Validate: ${latestStep.title}`, - stepIndex: maxStepIndex + 1, - laneId: latestStep.laneId, - dependencyStepKeys: [latestStep.stepKey], - executorKind: "opencode", - metadata: { - stepType: "validation", - taskType: "validation", - instructions: validatorPrompt, - modelId: validatorModelId, - role: validatorRoleName, - autoSpawnedValidation: true, - targetStepId: latestStep.id, - targetStepKey: latestStep.stepKey, - targetAttemptId: args.attemptId, - targetStepSummary: envelope.summary, - phaseKey: validationPhaseCard?.phaseKey ?? (stepPhaseKey || phaseCard?.phaseKey || null), - phaseName: validationPhaseCard?.name ?? (stepPhaseName || phaseCard?.name || null), - phasePosition: validationPhaseCard?.position - ?? (typeof latestStepMeta.phasePosition === "number" - ? latestStepMeta.phasePosition - : phaseCard?.position ?? null), - ...(validationPhaseCard?.model ? { phaseModel: validationPhaseCard.model } : {}), - ...(typeof validationPhaseCard?.instructions === "string" && validationPhaseCard.instructions.trim().length > 0 - ? { phaseInstructions: validationPhaseCard.instructions.trim() } - : {}), - ...(validationPhaseCard?.validationGate ? { phaseValidation: validationPhaseCard.validationGate } : {}), - ...(validationPhaseCard?.budget ? { phaseBudget: validationPhaseCard.budget } : {}), - validationContract: normalizedContract - } - } - ] - }); - if (spawned.length > 0) { - appendTimelineEvent({ - runId: run.id, - stepId: spawned[0]!.id, - eventType: "validation_auto_spawned", - reason: "dedicated_required_validation", - detail: { - targetStepId: latestStep.id, - targetStepKey: latestStep.stepKey, - validatorStepKey: spawned[0]!.stepKey, - validatorModelId - } - }); - void this.startReadyAutopilotAttempts({ - runId: run.id, - reason: "validation_auto_spawned" - }).catch(() => {}); - } - } - } - } else { - const reminder = `Step "${latestStep.stepKey}" requires self-validation. Review output and call report_validation with verdict pass/fail.`; - appendTimelineEvent({ - runId: run.id, - stepId: latestStep.id, - attemptId: args.attemptId, - eventType: "validation_self_check_reminder", - reason: "required_validation_missing", - detail: { - stepKey: latestStep.stepKey, - reminder - } - }); - persistRuntimeEvent({ - runId: run.id, - stepId: latestStep.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "validation_self_check_reminder", - eventKey: `validation_self_check_reminder:${run.id}:${latestStep.id}:${completedAt}`, - occurredAt: completedAt, - payload: { - audience: "coordinator", - stepId: latestStep.id, - stepKey: latestStep.stepKey, - message: reminder - } - }); - emit({ - type: "orchestrator-step-updated", - runId: run.id, - stepId: latestStep.id, - reason: "validation_self_check_reminder" - }); - validationSelfCheckReminder = true; - } - } - } - } - } else if (status === "canceled") { - db.run( - ` - update orchestrator_steps - set status = 'canceled', - updated_at = ?, - completed_at = ?, - last_attempt_id = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [completedAt, completedAt, args.attemptId, step.id, run.id, projectId] - ); - } else if (status === "blocked") { - const blockedMetadata = { - ...stepMetadata, - blockedAt: completedAt, - blockedByAttemptId: args.attemptId, - blockedErrorClass: errorClass, - blockedErrorMessage: effectiveErrorMessage ?? defaultSummary, - blockedSticky: errorClass === "policy" || naturalPlannerQuestion != null - }; - db.run( - ` - update orchestrator_steps - set status = 'blocked', - metadata_json = ?, - updated_at = ?, - last_attempt_id = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(blockedMetadata), completedAt, args.attemptId, step.id, run.id, projectId] - ); - } else { - if (shouldRetry) { - const nextRetryAt = new Date(Date.now() + computedBackoff).toISOString(); - const nextRetryCount = retryConsumesBudget ? step.retryCount + 1 : step.retryCount; - const restartInterruptedCountRaw = Number(stepMetadata.restartInterruptedRetryCount ?? 0); - const restartInterruptedRetryCount = - Number.isFinite(restartInterruptedCountRaw) ? Math.max(0, Math.floor(restartInterruptedCountRaw)) : 0; - db.run( - ` - update orchestrator_steps - set status = 'pending', - retry_count = ?, - metadata_json = ?, - updated_at = ?, - last_attempt_id = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [ - nextRetryCount, - JSON.stringify({ - ...(step.metadata ?? {}), - nextRetryAt, - lastRetryBackoffMs: computedBackoff, - ...(restartInterruptedFailure - ? { - lastRestartInterruptedAttemptId: args.attemptId, - lastRestartInterruptedAt: completedAt, - restartInterruptedRetryCount: restartInterruptedRetryCount + 1 - } - : {}) - }), - completedAt, - args.attemptId, - step.id, - run.id, - projectId - ] - ); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "attempt_retry_scheduled", - reason: "retryable_failure", - detail: { - retryBackoffMs: computedBackoff, - nextRetryAt, - retryCount: nextRetryCount, - retryLimit: step.retryLimit, - restartInterrupted: restartInterruptedFailure, - retryBudgetPreserved: !retryConsumesBudget - } - }); - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "retry_scheduled", - eventKey: `retry_scheduled:${args.attemptId}:${step.retryCount + 1}:${nextRetryAt}`, - occurredAt: completedAt, - payload: { - retryBackoffMs: computedBackoff, - nextRetryAt, - retryCount: nextRetryCount, - retryLimit: step.retryLimit, - errorClass, - restartInterrupted: restartInterruptedFailure, - retryBudgetPreserved: !retryConsumesBudget - } - }); - } else { - db.run( - ` - update orchestrator_steps - set status = 'failed', - updated_at = ?, - completed_at = ?, - last_attempt_id = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [completedAt, completedAt, args.attemptId, step.id, run.id, projectId] - ); - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "retry_exhausted", - eventKey: `retry_exhausted:${args.attemptId}:${step.retryCount}:${step.retryLimit}`, - occurredAt: completedAt, - payload: { - retryCount: step.retryCount, - retryLimit: step.retryLimit, - errorClass, - errorMessage: effectiveErrorMessage ?? defaultSummary - } - }); - } - } - - insertHandoff({ - missionId: run.missionId, - missionStepId: step.missionStepId, - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - handoffType: - status === "succeeded" - ? "attempt_succeeded" - : status === "failed" - ? "attempt_failed" - : status === "blocked" - ? "attempt_blocked" - : "attempt_canceled", - producer: "orchestrator", - payload: { - contextProfile: normalizeProfileId(attemptRow.context_profile), - status, - errorClass, - errorMessage: effectiveErrorMessage, - retryBackoffMs: computedBackoff, - result: envelope - } - }); - - emit({ type: "orchestrator-attempt-updated", runId: run.id, stepId: step.id, attemptId: args.attemptId, reason: "completed" }); - emit({ type: "orchestrator-step-updated", runId: run.id, stepId: step.id, reason: "attempt_completed" }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "attempt_completed", - reason: status, - detail: { - status, - errorClass, - retryBackoffMs: computedBackoff, - shouldRetry - } - }); - if (fileReservationMessage && fileReservationCheck) { - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "file_reservation_guard", - reason: reservationBlocks ? "block" : reservationWarns ? "warn" : "off", - detail: { - guardMode: reservationGuardMode, - normalizedScopes: fileReservationCheck.normalizedScopes, - touchedPaths: fileReservationCheck.touchedPaths, - violations: fileReservationCheck.violations, - rawPaths: fileReservationCheck.rawPaths - } - }); - } - if (softFailureOverride) { - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "soft_success_blocking_failure", - reason: softFailureOverride.category ?? "unknown", - detail: { - category: softFailureOverride.category, - detail: softFailureOverride.detail, - originalStatus: "succeeded", - overriddenStatus: "failed", - errorClass: "soft_success_blocking_failure" - } - }); - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "blocked", - eventKey: `soft_failure_override:${args.attemptId}:${softFailureOverride.category}:${completedAt}`, - occurredAt: completedAt, - payload: { - errorClass: "soft_success_blocking_failure", - category: softFailureOverride.category, - errorMessage: softFailureOverride.detail, - originalStatus: "succeeded" - } - }); - } - if (status === "succeeded") { - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "done", - eventKey: `done:${args.attemptId}:${completedAt}`, - occurredAt: completedAt, - payload: { - summary: envelope.summary - } - }); - if (validationContractUnfulfilledSignal) { - emit({ - type: "orchestrator-run-updated", - runId: run.id, - reason: "validation_contract_unfulfilled" - }); - } else if (validationSelfCheckReminder) { - emit({ - type: "orchestrator-run-updated", - runId: run.id, - reason: "validation_self_check_reminder" - }); - } - } else if (status === "blocked") { - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - sessionId: attemptRow.executor_session_id, - eventType: "blocked", - eventKey: `blocked:${args.attemptId}:${errorClass}:${completedAt}`, - occurredAt: completedAt, - payload: { - errorClass, - errorMessage: effectiveErrorMessage ?? defaultSummary - } - }); - } - - // Budget accumulation: if attempt metadata includes tokensConsumed, accumulate into run - const attemptTokens = Number(args.metadata?.tokensConsumed ?? 0); - if (attemptTokens > 0) { - const currentRunRow = getRunRow(run.id); - const currentRunMeta = currentRunRow ? (parseJsonRecord(currentRunRow.metadata_json) ?? {}) : (run.metadata ?? {}); - const currentTotal = Number(currentRunMeta.tokensConsumed ?? 0); - const newTotal = currentTotal + attemptTokens; - const updatedMeta = { ...currentRunMeta, tokensConsumed: newTotal }; - db.run( - ` - update orchestrator_runs - set metadata_json = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [JSON.stringify(updatedMeta), nowIso(), run.id, projectId] - ); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "budget_updated", - reason: "attempt_budget_accumulated", - detail: { - attemptTokens, - totalTokensConsumed: newTotal - } - }); - // Check if total exceeds limit; if so, pause the run - const runtimeConfig = getRuntimeConfig(); - if (runtimeConfig.maxTotalTokenBudget != null && newTotal >= runtimeConfig.maxTotalTokenBudget) { - updateRunStatus(run.id, "paused", { - last_error: `Total token budget exceeded: ${newTotal} >= ${runtimeConfig.maxTotalTokenBudget}`, - metadata_json: JSON.stringify(updatedMeta) - }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "budget_exceeded", - reason: "total_budget_limit", - detail: { - tokensConsumed: newTotal, - maxTotalTokenBudget: runtimeConfig.maxTotalTokenBudget - } - }); - // VAL-BUDGET-001: Emit runtime event so aiOrchestratorService can - // create a budget_limit_reached intervention (matching the hard cap - // path in coordinatorTools). - persistRuntimeEvent({ - runId: run.id, - stepId: step.id, - attemptId: args.attemptId, - eventType: "budget_exceeded", - eventKey: `budget_exceeded:${run.id}:${args.attemptId}:${newTotal}`, - payload: { - tokensConsumed: newTotal, - maxTotalTokenBudget: runtimeConfig.maxTotalTokenBudget, - missionId: run.missionId, - source: "completeAttempt", - } - }); - emit({ - type: "orchestrator-run-updated", - runId: run.id, - reason: "budget_exceeded", - }); - } - } - - // Append run narrative entry for step completion - appendRunNarrative(run.id, step.stepKey, `${status}: ${envelope.summary.slice(0, 200)}`); - - // VAL-STATE-002: When a step completes, check if it is a fan-out child - // and all siblings are terminal. If so, update the parent step status. - if (status === "succeeded" || (status === "failed" && !shouldRetry)) { - this.checkFanOutCompletion({ runId: run.id, completedStepKey: step.stepKey }); - } - - const updatedRun = this.tick({ runId: run.id }); - // When the run transitions to "completing" (all steps terminal) or is already terminal, - // mark worker state as disposed for cleanup. - if ( - updatedRun.status === "completing" || - updatedRun.status === "succeeded" || - updatedRun.status === "failed" || - updatedRun.status === "canceled" - ) { - const latestAttempt = getAttemptRow(args.attemptId); - const latestMetadata = - (latestAttempt ? toAttempt(latestAttempt).metadata : null) ?? parseJsonRecord(attemptRow.metadata_json) ?? {}; - db.run( - ` - update orchestrator_attempts - set metadata_json = ? - where id = ? - and project_id = ? - `, - [ - JSON.stringify({ - ...latestMetadata, - workerState: "disposed", - workerDisposedAt: nowIso() - }), - args.attemptId, - projectId - ] - ); - } - // Store last error on the run when a real failed attempt completes. - // App restart/shutdown interruptions are audit events, not mission failures. - if (status === "failed" && !restartInterruptedFailure) { - db.run( - ` - update orchestrator_runs - set last_error = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [effectiveErrorMessage ?? defaultSummary, nowIso(), run.id, projectId] - ); - } else if (restartInterruptedFailure) { - db.run( - ` - update orchestrator_runs - set last_error = null, - updated_at = ? - where id = ? - and project_id = ? - `, - [nowIso(), run.id, projectId] - ); - } - const updatedAttemptRow = getAttemptRow(args.attemptId); - if (!updatedAttemptRow) throw new Error("Attempt not found after completion update."); - return toAttempt(updatedAttemptRow); - }, - - resumeRun(args: { runId: string }): OrchestratorRun { - const runRow = getRunRow(args.runId); - if (!runRow) throw new Error(`Run not found: ${args.runId}`); - const run = toRun(runRow); - const failedRestartAttempts = db.all<AttemptRow>( - ` - select - attempt.id, - attempt.run_id, - attempt.step_id, - attempt.attempt_number, - attempt.status, - attempt.executor_kind, - attempt.executor_session_id, - attempt.tracked_session_enforced, - attempt.context_profile, - attempt.context_snapshot_id, - attempt.error_class, - attempt.error_message, - attempt.retry_backoff_ms, - attempt.result_envelope_json, - attempt.metadata_json, - attempt.created_at, - attempt.started_at, - attempt.completed_at - from orchestrator_attempts attempt - join orchestrator_steps step - on step.id = attempt.step_id - and step.project_id = attempt.project_id - and step.run_id = attempt.run_id - where attempt.project_id = ? - and attempt.run_id = ? - and attempt.status = 'failed' - and step.status = 'failed' - and step.last_attempt_id = attempt.id - and ( - attempt.error_class = 'resume_recovered' - or lower(coalesce(attempt.error_message, '')) like '%closed during shutdown%' - or lower(coalesce(attempt.error_message, '')) like '%orphaned by app restart%' - or lower(coalesce(attempt.error_message, '')) like '%worker session was lost%' - ) - order by attempt.created_at asc - `, - [projectId, run.id] - ); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - if (run.status !== "failed" || failedRestartAttempts.length === 0) return run; - updateRunStatus(run.id, "active", { - last_error: null - }); - } - if (run.status === "completing") return run; - if (run.status === "paused") { - updateRunStatus(run.id, "active", { - last_error: null - }); - } - - // Recover in-flight attempts as restart-failures so scheduler can retry deterministically. - const runningAttempts = db.all<AttemptRow>( - ` - select - id, - run_id, - step_id, - attempt_number, - status, - executor_kind, - executor_session_id, - tracked_session_enforced, - context_profile, - context_snapshot_id, - error_class, - error_message, - retry_backoff_ms, - result_envelope_json, - metadata_json, - created_at, - started_at, - completed_at - from orchestrator_attempts - where project_id = ? - and run_id = ? - and status = 'running' - order by created_at asc - `, - [projectId, run.id] - ); - - for (const attemptRow of runningAttempts) { - const step = getStepRow(attemptRow.step_id); - if (!step) continue; - releaseClaimsForAttempt({ attemptId: attemptRow.id, state: "expired" }); - const completedAt = nowIso(); - const stepMetadata = parseJsonRecord(step.metadata_json) ?? {}; - const recoveryCountRaw = Number(stepMetadata.resumeRecoveryCount ?? 0); - const recoveryCount = Number.isFinite(recoveryCountRaw) ? Math.max(0, Math.floor(recoveryCountRaw)) : 0; - const infrastructureRetryAvailable = recoveryCount < MAX_RESTART_RECOVERY_ATTEMPTS; - const shouldRetryRecoveredAttempt = infrastructureRetryAvailable; - const nextStepMetadata = { - ...stepMetadata, - lastResumeRecoveredAttemptId: attemptRow.id, - lastResumeRecoveredAt: completedAt, - ...(infrastructureRetryAvailable ? { resumeRecoveryCount: recoveryCount + 1 } : {}), - }; - db.run( - ` - update orchestrator_attempts - set status = 'failed', - error_class = 'resume_recovered', - error_message = ?, - result_envelope_json = ?, - completed_at = ?, - started_at = coalesce(started_at, ?) - where id = ? - and project_id = ? - `, - [ - "Attempt was orphaned by app restart; worker session was lost. Recovered into deterministic retry path.", - JSON.stringify({ - schema: "ade.orchestratorAttempt.v1", - success: false, - summary: "Recovered after app restart; worker session lost.", - outputs: null, - warnings: ["resume_recovered"], - sessionId: attemptRow.executor_session_id, - trackedSession: true - } satisfies OrchestratorAttemptResultEnvelope), - completedAt, - completedAt, - attemptRow.id, - projectId - ] - ); - db.run( - ` - update orchestrator_steps - set status = ?, - retry_count = ?, - last_attempt_id = ?, - metadata_json = ?, - updated_at = ?, - completed_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [ - shouldRetryRecoveredAttempt ? "ready" : "failed", - step.retry_count, - attemptRow.id, - JSON.stringify(nextStepMetadata), - completedAt, - shouldRetryRecoveredAttempt ? null : completedAt, - step.id, - run.id, - projectId - ] - ); - insertHandoff({ - missionId: run.missionId, - missionStepId: step.mission_step_id, - runId: run.id, - stepId: step.id, - attemptId: attemptRow.id, - handoffType: "attempt_recovered_after_restart", - producer: "orchestrator", - payload: { - errorClass: "resume_recovered", - message: "Attempt orphaned by app restart and converted to deterministic recovery path.", - contextProfile: normalizeProfileId(attemptRow.context_profile) - } - }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attemptRow.id, - eventType: "attempt_recovered_after_restart", - reason: "resume_recovered", - detail: { - retryLimit: step.retry_limit, - retryCount: step.retry_count, - restartRecoveryCount: infrastructureRetryAvailable ? recoveryCount + 1 : recoveryCount, - infrastructureRetryGranted: infrastructureRetryAvailable - } - }); - emit({ type: "orchestrator-attempt-updated", runId: run.id, stepId: step.id, attemptId: attemptRow.id, reason: "completed" }); - emit({ type: "orchestrator-step-updated", runId: run.id, stepId: step.id, reason: "attempt_completed" }); - } - - for (const attemptRow of failedRestartAttempts) { - const step = getStepRow(attemptRow.step_id); - if (!step || step.status !== "failed" || step.last_attempt_id !== attemptRow.id) continue; - const completedAt = nowIso(); - const stepMetadata = parseJsonRecord(step.metadata_json) ?? {}; - const recoveryCountRaw = Number(stepMetadata.resumeRecoveryCount ?? 0); - const recoveryCount = Number.isFinite(recoveryCountRaw) ? Math.max(0, Math.floor(recoveryCountRaw)) : 0; - const scheduledRetryAttempts = db.all<{ error_class: string | null; error_message: string | null }>( - ` - select attempt.error_class, attempt.error_message - from orchestrator_runtime_events event - join orchestrator_attempts attempt - on attempt.id = event.attempt_id - and attempt.project_id = event.project_id - where event.project_id = ? - and event.run_id = ? - and event.step_id = ? - and event.event_type = 'retry_scheduled' - `, - [projectId, run.id, step.id] - ); - const restoredRetryCount = - scheduledRetryAttempts.length > 0 - ? Math.min( - step.retry_limit, - scheduledRetryAttempts.filter( - (entry) => !isRestartInterruptedAttemptFailure({ - errorClass: entry.error_class, - errorMessage: entry.error_message - }) - ).length - ) - : Math.max(0, step.retry_count - 1); - db.run( - ` - update orchestrator_steps - set status = 'pending', - retry_count = ?, - last_attempt_id = ?, - metadata_json = ?, - updated_at = ?, - completed_at = null - where id = ? - and run_id = ? - and project_id = ? - `, - [ - restoredRetryCount, - attemptRow.id, - JSON.stringify({ - ...stepMetadata, - lastResumeRecoveredAttemptId: attemptRow.id, - lastResumeRecoveredAt: completedAt, - resumeRecoveryCount: recoveryCount + 1, - restoredRetryCountAfterRestartFailure: restoredRetryCount, - restartInterruptedFailureRecovered: true - }), - completedAt, - step.id, - run.id, - projectId - ] - ); - insertHandoff({ - missionId: run.missionId, - missionStepId: step.mission_step_id, - runId: run.id, - stepId: step.id, - attemptId: attemptRow.id, - handoffType: "attempt_recovered_after_restart", - producer: "orchestrator", - payload: { - errorClass: attemptRow.error_class ?? "executor_failure", - message: "Previously failed attempt was caused by app restart/shutdown and was restored to the scheduler.", - restoredRetryCount, - contextProfile: normalizeProfileId(attemptRow.context_profile) - } - }); - appendTimelineEvent({ - runId: run.id, - stepId: step.id, - attemptId: attemptRow.id, - eventType: "attempt_recovered_after_restart", - reason: "restart_interrupted_failure", - detail: { - retryLimit: step.retry_limit, - retryCount: restoredRetryCount, - retryBudgetRestored: restoredRetryCount !== step.retry_count - } - }); - emit({ type: "orchestrator-step-updated", runId: run.id, stepId: step.id, reason: "attempt_recovered_after_restart" }); - } - - if (failedRestartAttempts.length > 0) { - db.run( - ` - update orchestrator_runs - set last_error = null, - updated_at = ? - where id = ? - and project_id = ? - `, - [nowIso(), run.id, projectId] - ); - } - - const resumed = this.tick({ runId: run.id }); - appendTimelineEvent({ - runId: run.id, - eventType: "run_resumed", - reason: "resume_run", - detail: { - recoveredAttempts: runningAttempts.length + failedRestartAttempts.length, - recoveredRunningAttempts: runningAttempts.length, - recoveredFailedRestartAttempts: failedRestartAttempts.length, - status: resumed.status - } - }); - return resumed; - }, - - pauseRun(args: { runId: string; reason?: string; metadata?: Record<string, unknown> | null }): OrchestratorRun { - const runRow = getRunRow(args.runId); - if (!runRow) throw new Error(`Run not found: ${args.runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status) || run.status === "paused") return run; - - const patch: Record<string, SqlValue> = {}; - if (typeof args.reason === "string" && args.reason.trim().length > 0) { - patch.last_error = args.reason.trim(); - } - if (args.metadata && typeof args.metadata === "object" && !Array.isArray(args.metadata)) { - patch.metadata_json = JSON.stringify({ - ...(parseJsonRecord(runRow.metadata_json) ?? {}), - ...args.metadata - }); - } - - updateRunStatus(run.id, "paused", patch); - appendTimelineEvent({ - runId: run.id, - eventType: "run_paused", - reason: args.reason ?? "paused", - detail: { - previousStatus: run.status, - reason: args.reason ?? null - } - }); - const updated = getRunRow(run.id); - if (!updated) throw new Error(`Run not found after pause: ${run.id}`); - return toRun(updated); - }, - - cancelRun(args: { runId: string; reason?: string }) { - const run = getRunRow(args.runId); - if (!run) throw new Error(`Run not found: ${args.runId}`); - const now = nowIso(); - db.run( - ` - update orchestrator_attempts - set status = 'canceled', - error_class = 'canceled', - error_message = ?, - completed_at = coalesce(completed_at, ?) - where run_id = ? - and project_id = ? - and status in ('queued', 'running', 'blocked') - `, - [args.reason ?? "Run canceled.", now, args.runId, projectId] - ); - db.run( - ` - update orchestrator_steps - set status = case - when status in ('succeeded', 'failed', 'skipped', 'canceled') then status - else 'canceled' - end, - updated_at = ?, - completed_at = case - when status in ('succeeded', 'failed', 'skipped', 'canceled') then completed_at - else ? - end - where run_id = ? - and project_id = ? - `, - [now, now, args.runId, projectId] - ); - db.run( - ` - update orchestrator_claims - set state = 'released', - released_at = ? - where run_id = ? - and project_id = ? - and state = 'active' - `, - [now, args.runId, projectId] - ); - updateRunStatus(args.runId, "canceled", { - last_error: args.reason ?? null - }); - appendTimelineEvent({ - runId: args.runId, - eventType: "run_canceled", - reason: "cancel_run", - detail: { - reason: args.reason ?? null - } - }); - try { - void this.generateRunRetrospective({ runId: args.runId }); - } catch { - // best-effort - } - }, - - addSteps(args: { - runId: string; - steps: StartOrchestratorRunStepInput[]; - }): OrchestratorStep[] { - const runId = String(args.runId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - // Only reject step additions for truly terminal states. - // "bootstrapping", "active", "completing", "paused", "queued" all allow step additions. - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot add steps to a terminal run (status: ${run.status}).`); - } - if (!args.steps.length) return []; - const missionLaneId = run.missionId ? resolveRunMissionLaneId(run.metadata) : null; - - // Get existing steps to compute next step_index and resolve dependency keys - const existingStepRows = listStepRows(runId); - const existingSteps = existingStepRows.map(toStep); - const existingKeyToId = new Map<string, string>(); - for (const step of existingSteps) { - existingKeyToId.set(step.stepKey, step.id); - } - const existingKeyById = new Map<string, string>(); - for (const step of existingSteps) { - existingKeyById.set(step.id, step.stepKey); - } - const maxExistingIndex = existingSteps.reduce((max, step) => Math.max(max, step.stepIndex), -1); - - const createdAt = nowIso(); - const newKeyToId = new Map<string, string>(); - const dependencyStepKeysByNewStepKey = new Map<string, string[]>(); - const sorted = [...args.steps].sort( - (a, b) => a.stepIndex - b.stepIndex || a.stepKey.localeCompare(b.stepKey) - ); - const stepEntries = sorted.map((input, index) => { - const id = randomUUID(); - const stepKey = input.stepKey.trim(); - if (!stepKey) throw new Error("stepKey is required for every orchestrator step."); - if (existingKeyToId.has(stepKey) || newKeyToId.has(stepKey)) { - throw new Error(`Duplicate stepKey: ${stepKey}`); - } - newKeyToId.set(stepKey, id); - const dependencyStepKeys = normalizeDependencyStepKeys(input.dependencyStepKeys); - dependencyStepKeysByNewStepKey.set(stepKey, dependencyStepKeys); - return { - id, - input, - stepIndex: Number.isFinite(input.stepIndex) ? input.stepIndex : maxExistingIndex + 1 + index, - stepKey, - dependencyStepKeys - }; - }); - - const existingGraphSteps: StepGraphValidationStep[] = existingSteps.map((step) => ({ - stepKey: step.stepKey, - dependencyStepKeys: step.dependencyStepIds - .map((depId) => existingKeyById.get(depId) ?? "") - .filter((depKey): depKey is string => depKey.length > 0), - joinPolicy: step.joinPolicy, - quorumCount: step.quorumCount - })); - const newGraphSteps: StepGraphValidationStep[] = stepEntries.map(({ stepKey, dependencyStepKeys, input }) => ({ - stepKey, - dependencyStepKeys, - joinPolicy: normalizeJoinPolicy(String(input.joinPolicy ?? "all_success")), - quorumCount: input.quorumCount != null ? Math.floor(Number(input.quorumCount)) : null - })); - validateStepGraphIntegrity({ - context: "addSteps", - steps: [...existingGraphSteps, ...newGraphSteps] - }); - - // Insert step rows - for (const { id, input, stepIndex, stepKey } of stepEntries) { - const explicitLaneId = toOptionalNonEmptyString(input.laneId); - const effectiveLaneId = explicitLaneId ?? missionLaneId; - const policy: Record<string, unknown> = { - ...(typeof input.policy?.docsMaxBytes === "number" ? { docsMaxBytes: Math.floor(input.policy.docsMaxBytes) } : {}), - claimScopes: Array.isArray(input.policy?.claimScopes) - ? input.policy?.claimScopes?.map((scope) => ({ - scopeKind: scope.scopeKind, - scopeValue: scope.scopeValue, - ...(typeof scope.ttlMs === "number" ? { ttlMs: Math.floor(scope.ttlMs) } : {}) - })) - : [] - }; - const stepMetadata = normalizePlanningQuestionStepMetadata({ - missionId: run.missionId, - runMetadata: run.metadata, - metadata: { - ...(input.metadata ?? {}), - ...(input.executorKind ? { executorKind: input.executorKind } : {}), - policy - }, - }); - const metadataJson = JSON.stringify(stepMetadata); - db.run( - ` - insert into orchestrator_steps( - id, - run_id, - project_id, - mission_step_id, - step_key, - step_index, - title, - lane_id, - status, - join_policy, - quorum_count, - dependency_step_ids_json, - retry_limit, - retry_count, - last_attempt_id, - policy_json, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, '[]', ?, 0, null, ?, ?, ?, ?, null, null) - `, - [ - id, - runId, - projectId, - input.missionStepId ?? null, - stepKey, - stepIndex, - input.title.trim() || stepKey, - effectiveLaneId, - input.joinPolicy ?? "all_success", - input.quorumCount ?? null, - Math.max(0, Math.floor(input.retryLimit ?? 0)), - JSON.stringify(policy), - metadataJson, - createdAt, - createdAt - ] - ); - appendTimelineEvent({ - runId, - stepId: id, - eventType: "step_registered", - reason: "add_steps", - detail: { - stepKey, - stepIndex, - joinPolicy: input.joinPolicy ?? "all_success", - retryLimit: Math.max(0, Math.floor(input.retryLimit ?? 0)), - laneId: effectiveLaneId, - } - }); - } - - // Resolve dependency IDs from keys (can reference both existing and new steps) - const combinedKeyToId = new Map([...existingKeyToId, ...newKeyToId]); - for (const { id, stepKey } of stepEntries) { - const depKeys = dependencyStepKeysByNewStepKey.get(stepKey) ?? []; - const depIds = depKeys.map((key) => { - const depId = combinedKeyToId.get(key); - if (!depId) { - throw new Error(`Unknown dependency stepKey '${key}' referenced by step '${stepKey}'.`); - } - return depId; - }); - db.run( - ` - update orchestrator_steps - set dependency_step_ids_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(depIds), createdAt, id, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId: id, - eventType: "step_dependencies_resolved", - reason: "add_steps", - detail: { dependencyStepIds: depIds } - }); - } - - if (run.status === "completing" && stepEntries.length > 0) { - updateRunStatus(runId, "active"); - appendTimelineEvent({ - runId, - eventType: "run_reactivated", - reason: "completion_recovery_steps_added", - detail: { - previousStatus: run.status, - newStepCount: stepEntries.length - } - }); - } - - // Re-evaluate readiness and emit - refreshStepReadiness(runId); - emit({ type: "orchestrator-run-updated", runId, reason: "steps_added" }); - - return stepEntries.map(({ id }) => { - const row = getStepRow(id); - if (!row) throw new Error(`Step not found after insertion: ${id}`); - return toStep(row); - }); - }, - - /** - * Like addSteps, but allows adding steps to a terminal (succeeded/failed) run. - * Transitions the run back to "active" before inserting steps, enabling - * post-completion workflows like conflict resolution workers. - */ - addPostCompletionSteps(args: { - runId: string; - steps: StartOrchestratorRunStepInput[]; - }): OrchestratorStep[] { - const runId = String(args.runId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - - // If the run is terminal, transition back to active so new steps can be scheduled - if (TERMINAL_RUN_STATUSES.has(run.status)) { - updateRunStatus(runId, "active"); - appendTimelineEvent({ - runId, - eventType: "run_reopened", - reason: "post_completion_steps", - detail: { previousStatus: run.status, newStepCount: args.steps.length } - }); - emit({ type: "orchestrator-run-updated", runId, reason: "run_reopened" }); - } - - // Delegate to addSteps which now operates on a non-terminal run - return this.addSteps(args); - }, - - // ─── Fan-out Step Injection ──────────────────────────────────── - - /** - * Execute an external_parallel fan-out: create child steps from subtasks, - * wire them to depend on the parent step, and update any downstream - * successor steps to depend on ALL children (fan-in). - */ - executeFanOutExternal(args: { - runId: string; - parentStepKey: string; - decision: FanOutDecision; - }): OrchestratorStep[] { - const { runId, parentStepKey, decision } = args; - if (!decision.subtasks.length) return []; - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const existingSteps = listStepRows(runId).map(toStep); - const maxIndex = existingSteps.reduce((max, s) => Math.max(max, s.stepIndex), -1); - - // Find successor steps that depend on the parent - const parentStep = existingSteps.find((s) => s.stepKey === parentStepKey); - const parentId = parentStep?.id; - const successorSteps = parentId - ? existingSteps.filter((s) => s.dependencyStepIds.includes(parentId)) - : []; - - // Create child steps - const childStepKeys: string[] = []; - const childStepInputs: StartOrchestratorRunStepInput[] = decision.subtasks.map((subtask, i) => { - const childKey = `${parentStepKey}_fanout_${i}`; - childStepKeys.push(childKey); - return { - stepKey: childKey, - title: subtask.title, - stepIndex: maxIndex + 1 + i, - dependencyStepKeys: [parentStepKey], - retryLimit: 1, - metadata: { - fanOutParent: parentStepKey, - fanOutStrategy: decision.strategy, - instructions: subtask.instructions, - files: subtask.files, - complexity: subtask.complexity, - ...(subtask.estimatedTokens != null ? { estimatedTokens: subtask.estimatedTokens } : {}) - } - }; - }); - - const createdSteps = this.addSteps({ runId, steps: childStepInputs }); - - // Update parent step metadata with fan-out tracking - if (parentStep) { - const parentMeta = (parentStep.metadata ?? {}) as Record<string, unknown>; - const updatedMeta = { - ...parentMeta, - fanOut: { ...(typeof parentMeta.fanOut === "object" && parentMeta.fanOut ? parentMeta.fanOut : {}), enabled: true, maxChildren: 8 }, - fanOutChildren: childStepKeys, - fanOutStrategy: decision.strategy, - fanOutComplete: false - }; - db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ? and run_id = ? and project_id = ?`, - [JSON.stringify(updatedMeta), nowIso(), parentStep.id, runId, projectId] - ); - } - - // Rewire successors: each successor now depends on ALL children instead of the parent - for (const successor of successorSteps) { - const existingDepKeys = successor.dependencyStepIds - .map((depId) => existingSteps.find((s) => s.id === depId)?.stepKey) - .filter((key): key is string => key != null); - // Replace the parent key with all child keys - const newDepKeys = existingDepKeys - .filter((key) => key !== parentStepKey) - .concat(childStepKeys); - this.updateStepDependencies({ - runId, - stepId: successor.id, - dependencyStepKeys: newDepKeys - }); - } - - appendTimelineEvent({ - runId, - stepId: parentStep?.id ?? null, - eventType: "fan_out_dispatched", - reason: "external_parallel", - detail: { - parentStepKey, - strategy: decision.strategy, - childCount: createdSteps.length, - childStepKeys, - reasoning: decision.reasoning - } - }); - - return createdSteps; - }, - - /** - * Execute a hybrid fan-out: group subtasks into clusters, each cluster - * becomes one external agent. Within each cluster, the agent handles - * multiple subtasks using internal parallelism. - */ - executeFanOutHybrid(args: { - runId: string; - parentStepKey: string; - decision: FanOutDecision; - }): OrchestratorStep[] { - const { runId, parentStepKey, decision } = args; - if (!decision.subtasks.length || !decision.clusters?.length) { - // Fallback to external parallel if no clusters defined - return this.executeFanOutExternal(args); - } - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const existingSteps = listStepRows(runId).map(toStep); - const maxIndex = existingSteps.reduce((max, s) => Math.max(max, s.stepIndex), -1); - - const parentStep = existingSteps.find((s) => s.stepKey === parentStepKey); - const parentId = parentStep?.id; - const successorSteps = parentId - ? existingSteps.filter((s) => s.dependencyStepIds.includes(parentId)) - : []; - - // Create one step per cluster - const childStepKeys: string[] = []; - const childStepInputs: StartOrchestratorRunStepInput[] = decision.clusters.map((cluster, clusterIdx) => { - const childKey = `${parentStepKey}_fanout_cluster_${clusterIdx}`; - childStepKeys.push(childKey); - const clusterSubtasks = cluster.subtaskIndices - .filter((i) => i >= 0 && i < decision.subtasks.length) - .map((i) => decision.subtasks[i]); - const combinedInstructions = clusterSubtasks - .map((st, j) => `## Subtask ${j + 1}: ${st.title}\n${st.instructions}`) - .join("\n\n"); - const allFiles = [...new Set(clusterSubtasks.flatMap((st) => st.files))]; - return { - stepKey: childKey, - title: `Cluster ${clusterIdx + 1}: ${cluster.reason || clusterSubtasks.map((s) => s.title).join(", ")}`, - stepIndex: maxIndex + 1 + clusterIdx, - dependencyStepKeys: [parentStepKey], - retryLimit: 1, - metadata: { - fanOutParent: parentStepKey, - fanOutStrategy: "hybrid", - clusterIndex: clusterIdx, - clusterReason: cluster.reason, - instructions: combinedInstructions, - files: allFiles, - subtaskCount: clusterSubtasks.length - } - }; - }); - - const createdSteps = this.addSteps({ runId, steps: childStepInputs }); - - // Update parent metadata - if (parentStep) { - const parentMeta = (parentStep.metadata ?? {}) as Record<string, unknown>; - const updatedMeta = { - ...parentMeta, - fanOut: { ...(typeof parentMeta.fanOut === "object" && parentMeta.fanOut ? parentMeta.fanOut : {}), enabled: true, maxChildren: 8 }, - fanOutChildren: childStepKeys, - fanOutStrategy: "hybrid", - fanOutComplete: false - }; - db.run( - `update orchestrator_steps set metadata_json = ?, updated_at = ? where id = ? and run_id = ? and project_id = ?`, - [JSON.stringify(updatedMeta), nowIso(), parentStep.id, runId, projectId] - ); - } - - // Rewire successors to depend on all cluster steps - for (const successor of successorSteps) { - const existingDepKeys = successor.dependencyStepIds - .map((depId) => existingSteps.find((s) => s.id === depId)?.stepKey) - .filter((key): key is string => key != null); - const newDepKeys = existingDepKeys - .filter((key) => key !== parentStepKey) - .concat(childStepKeys); - this.updateStepDependencies({ - runId, - stepId: successor.id, - dependencyStepKeys: newDepKeys - }); - } - - appendTimelineEvent({ - runId, - stepId: parentStep?.id ?? null, - eventType: "fan_out_dispatched", - reason: "hybrid", - detail: { - parentStepKey, - strategy: "hybrid", - clusterCount: createdSteps.length, - childStepKeys, - reasoning: decision.reasoning - } - }); - - return createdSteps; - }, - - /** - * Check if all fan-out children of a parent step have completed. - * If so, mark fanOutComplete on the parent and emit a timeline event. - */ - checkFanOutCompletion(args: { runId: string; completedStepKey: string }): boolean { - const { runId, completedStepKey } = args; - const allSteps = listStepRows(runId).map(toStep); - const stepByKey = new Map<string, OrchestratorStep>(); - for (const step of allSteps) { - if (!stepByKey.has(step.stepKey)) { - stepByKey.set(step.stepKey, step); - } - } - const completedStep = stepByKey.get(completedStepKey); - if (!completedStep) return false; - - // Find the parent via fanOutParent in metadata - const meta = (completedStep.metadata ?? {}) as Record<string, unknown>; - const parentStepKey = meta.fanOutParent as string | undefined; - if (!parentStepKey) return false; - - const parentStep = stepByKey.get(parentStepKey); - if (!parentStep) return false; - - const parentMeta = (parentStep.metadata ?? {}) as Record<string, unknown>; - const childKeys = parentMeta.fanOutChildren as string[] | undefined; - if (!Array.isArray(childKeys) || childKeys.length === 0) return false; - - // Check if all children are in a terminal state - const terminalStatuses = new Set<string>(["succeeded", "failed", "skipped", "superseded", "canceled"]); - const allDone = childKeys.every((key) => { - const child = stepByKey.get(key); - return child && terminalStatuses.has(child.status); - }); - - if (allDone && parentMeta.fanOutComplete !== true) { - const updatedMeta = { ...parentMeta, fanOutComplete: true }; - const now = nowIso(); - let succeededCount = 0; - let failedCount = 0; - for (const key of childKeys) { - const child = stepByKey.get(key); - if (child?.status === "succeeded") { - succeededCount += 1; - } else if (child?.status === "failed") { - failedCount += 1; - } - } - - // VAL-STATE-002: Update parent step status to reflect variant outcomes. - // If any child succeeded → parent succeeded; if all failed → parent failed. - const parentTerminalStatus = succeededCount > 0 ? "succeeded" : "failed"; - db.run( - `update orchestrator_steps set status = ?, metadata_json = ?, updated_at = ?, completed_at = coalesce(completed_at, ?) where id = ? and run_id = ? and project_id = ?`, - [parentTerminalStatus, JSON.stringify(updatedMeta), now, now, parentStep.id, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId: parentStep.id, - eventType: "fan_out_complete", - reason: "all_children_done", - detail: { - parentStepKey, - childKeys, - childCount: childKeys.length, - succeeded: succeededCount, - failed: failedCount, - parentTerminalStatus, - } - }); - emit({ type: "orchestrator-step-updated", runId, stepId: parentStep.id, reason: "fan_out_complete" }); - return true; - } - - return false; - }, - - skipStep(args: { - runId: string; - stepId: string; - reason?: string; - allowTerminal?: boolean; - }): OrchestratorStep { - const runId = String(args.runId ?? "").trim(); - const stepId = String(args.stepId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!stepId) throw new Error("stepId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot skip step in a terminal run (status: ${run.status}).`); - } - - const stepRow = getStepRow(stepId); - if (!stepRow || stepRow.run_id !== runId) throw new Error(`Step not found in run: ${stepId}`); - const step = toStep(stepRow); - if (TERMINAL_STEP_STATUSES.has(step.status)) { - const canReclassifyTerminal = - args.allowTerminal === true - && (step.status === "failed" || step.status === "canceled"); - if (!canReclassifyTerminal) { - throw new Error(`Step is already terminal (status: ${step.status}).`); - } - } - - const now = nowIso(); - const reason = args.reason?.trim() || "Manually skipped."; - const existingMeta = - step.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata) - ? (step.metadata as Record<string, unknown>) - : {}; - const metadata = { - ...existingMeta, - skippedAt: now, - skippedReason: reason, - skippedFromStatus: step.status, - }; - db.run( - ` - update orchestrator_steps - set status = 'skipped', - metadata_json = ?, - updated_at = ?, - completed_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(metadata), now, now, stepId, runId, projectId] - ); - - appendTimelineEvent({ - runId, - stepId, - eventType: "step_skipped", - reason: "skip_step", - detail: { reason } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "skipped" }); - - // Re-evaluate downstream steps that may now be unblocked - refreshStepReadiness(runId); - - // Re-derive run status using policy-aware evaluation - const nextRunStatus = evaluateRunStatusWithPolicy(runId); - const currentRunStatus = normalizeRunStatus(runRow.status); - if (nextRunStatus !== currentRunStatus) { - updateRunStatus(runId, nextRunStatus); - } - - const updatedRow = getStepRow(stepId); - if (!updatedRow) throw new Error(`Step not found after skip: ${stepId}`); - return toStep(updatedRow); - }, - - async supersedeStep(args: { - runId: string; - stepId: string; - replacementStepId?: string | null; - replacementStepKey?: string | null; - reason?: string; - }): Promise<OrchestratorStep> { - const runId = String(args.runId ?? "").trim(); - const stepId = String(args.stepId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!stepId) throw new Error("stepId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot supersede step in a terminal run (status: ${run.status}).`); - } - - const stepRow = getStepRow(stepId); - if (!stepRow || stepRow.run_id !== runId) throw new Error(`Step not found in run: ${stepId}`); - const step = toStep(stepRow); - if (step.status === "superseded") return step; - if (TERMINAL_STEP_STATUSES.has(step.status)) { - throw new Error(`Step is already terminal (status: ${step.status}).`); - } - - const runningAttempts = listAttemptRows(runId) - .filter((attempt) => attempt.step_id === stepId && attempt.status === "running") - .map(toAttempt); - for (const runningAttempt of runningAttempts) { - await this.completeAttempt({ - attemptId: runningAttempt.id, - status: "canceled", - errorClass: "canceled", - errorMessage: "Step superseded by plan revision." - }); - } - - const now = nowIso(); - const reason = args.reason?.trim() || "Superseded by revised plan."; - const replacementStepId = args.replacementStepId ? String(args.replacementStepId).trim() : null; - const replacementStepKey = args.replacementStepKey ? String(args.replacementStepKey).trim() : null; - const existingMeta = - step.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata) - ? (step.metadata as Record<string, unknown>) - : {}; - const metadata = { - ...existingMeta, - superseded: true, - supersededAt: now, - supersededReason: reason, - supersededByStepId: replacementStepId, - supersededByStepKey: replacementStepKey, - priorStatus: step.status - }; - - db.run( - ` - update orchestrator_steps - set status = 'superseded', - metadata_json = ?, - updated_at = ?, - completed_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(metadata), now, now, stepId, runId, projectId] - ); - - insertHandoff({ - missionId: run.missionId, - missionStepId: step.missionStepId, - runId: run.id, - stepId: step.id, - attemptId: null, - handoffType: "step_superseded", - producer: "coordinator", - payload: { - reason, - replacementStepId, - replacementStepKey - } - }); - - appendTimelineEvent({ - runId, - stepId, - eventType: "step_superseded", - reason: "supersede_step", - detail: { - reason, - replacementStepId, - replacementStepKey - } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "superseded" }); - - refreshStepReadiness(runId); - const nextRunStatus = evaluateRunStatusWithPolicy(runId); - const currentRunStatus = normalizeRunStatus(runRow.status); - if (nextRunStatus !== currentRunStatus) { - updateRunStatus(runId, nextRunStatus); - } - - const updatedRow = getStepRow(stepId); - if (!updatedRow) throw new Error(`Step not found after supersede: ${stepId}`); - return toStep(updatedRow); - }, - - transferStepLane(args: { - runId: string; - stepId: string; - laneId: string | null; - reason: string; - transferredBy?: string; - allowTerminal?: boolean; - }): OrchestratorStep { - const runId = String(args.runId ?? "").trim(); - const stepId = String(args.stepId ?? "").trim(); - const laneId = args.laneId == null ? null : String(args.laneId).trim(); - const reason = String(args.reason ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!stepId) throw new Error("stepId is required."); - if (!reason) throw new Error("reason is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot transfer lane in a terminal run (status: ${run.status}).`); - } - - const stepRow = getStepRow(stepId); - if (!stepRow || stepRow.run_id !== runId) throw new Error(`Step not found in run: ${stepId}`); - const step = toStep(stepRow); - if (TERMINAL_STEP_STATUSES.has(step.status) && args.allowTerminal !== true) { - throw new Error(`Step is already terminal (status: ${step.status}).`); - } - if ((step.laneId ?? null) === laneId) { - return step; - } - if (laneId) { - const laneExists = db.get<{ id: string }>( - `select id from lanes where id = ? and project_id = ? limit 1`, - [laneId, projectId] - ); - if (!laneExists?.id) { - throw new Error(`Lane not found: ${laneId}`); - } - } - - const now = nowIso(); - const actor = args.transferredBy?.trim() || "coordinator"; - const existingMeta = - step.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata) - ? (step.metadata as Record<string, unknown>) - : {}; - const history = Array.isArray(existingMeta.laneTransferHistory) - ? [...existingMeta.laneTransferHistory] - : []; - history.push({ - fromLaneId: step.laneId ?? null, - toLaneId: laneId, - reason, - transferredBy: actor, - at: now - }); - const metadata = { - ...existingMeta, - laneTransferHistory: history.slice(-20), - laneTransferReason: reason, - lastTransferredBy: actor, - lastTransferredAt: now - }; - - db.run( - ` - update orchestrator_steps - set lane_id = ?, - metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [laneId, JSON.stringify(metadata), now, stepId, runId, projectId] - ); - - insertHandoff({ - missionId: run.missionId, - missionStepId: step.missionStepId, - runId: run.id, - stepId: step.id, - attemptId: null, - handoffType: "lane_transfer", - producer: actor, - payload: { - fromLaneId: step.laneId ?? null, - toLaneId: laneId, - reason - } - }); - - appendTimelineEvent({ - runId, - stepId, - eventType: "lane_transfer", - reason: "transfer_step_lane", - detail: { - fromLaneId: step.laneId ?? null, - toLaneId: laneId, - reason, - transferredBy: actor - } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "lane_transferred" }); - - const updatedRow = getStepRow(stepId); - if (!updatedRow) throw new Error(`Step not found after lane transfer: ${stepId}`); - return toStep(updatedRow); - }, - - // ─── Step Dependency & Consolidation ────────────────────────── - - updateStepDependencies(args: { - runId: string; - stepId: string; - dependencyStepKeys: string[]; - }): OrchestratorStep { - const runId = String(args.runId ?? "").trim(); - const stepId = String(args.stepId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!stepId) throw new Error("stepId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot update step dependencies in a terminal run (status: ${run.status}).`); - } - - const stepRow = getStepRow(stepId); - if (!stepRow || stepRow.run_id !== runId) throw new Error(`Step not found in run: ${stepId}`); - const step = toStep(stepRow); - if (TERMINAL_STEP_STATUSES.has(step.status)) { - throw new Error(`Step is already terminal (status: ${step.status}).`); - } - - // Build key<->id maps from all steps in this run - const allStepRows = listStepRows(runId); - const allSteps = allStepRows.map(toStep); - const keyToId = new Map<string, string>(); - const idToKey = new Map<string, string>(); - for (const s of allSteps) { - keyToId.set(s.stepKey, s.id); - idToKey.set(s.id, s.stepKey); - } - - // Normalize and resolve dependency keys to IDs - const dependencyStepKeys = normalizeDependencyStepKeys(args.dependencyStepKeys); - const depIds = dependencyStepKeys.map((key) => { - const depId = keyToId.get(key); - if (!depId) { - throw new Error(`Unknown dependency stepKey '${key}' referenced by step '${step.stepKey}'.`); - } - return depId; - }); - - // Validate the full graph with updated dependencies - const graphSteps: StepGraphValidationStep[] = allSteps.map((s) => ({ - stepKey: s.stepKey, - dependencyStepKeys: s.id === stepId - ? dependencyStepKeys - : s.dependencyStepIds - .map((depId) => idToKey.get(depId) ?? "") - .filter((depKey): depKey is string => depKey.length > 0), - joinPolicy: s.joinPolicy, - quorumCount: s.quorumCount - })); - validateStepGraphIntegrity({ - context: "updateStepDependencies", - steps: graphSteps - }); - - // Persist the updated dependency list - const now = nowIso(); - db.run( - ` - update orchestrator_steps - set dependency_step_ids_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(depIds), now, stepId, runId, projectId] - ); - - appendTimelineEvent({ - runId, - stepId, - eventType: "step_dependencies_updated", - reason: "update_step_dependencies", - detail: { dependencyStepKeys, dependencyStepIds: depIds } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "dependencies_updated" }); - - // Refresh readiness so newly-unblocked steps can proceed - refreshStepReadiness(runId); - - const resultRow = getStepRow(stepId); - if (!resultRow) throw new Error(`Step not found after dependency update: ${stepId}`); - return toStep(resultRow); - }, - - consolidateSteps(args: { - runId: string; - keepStepId: string; - removeStepId: string; - mergedInstructions: string; - }): OrchestratorStep { - const runId = String(args.runId ?? "").trim(); - const keepStepId = String(args.keepStepId ?? "").trim(); - const removeStepId = String(args.removeStepId ?? "").trim(); - const mergedInstructions = String(args.mergedInstructions ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!keepStepId) throw new Error("keepStepId is required."); - if (!removeStepId) throw new Error("removeStepId is required."); - if (!mergedInstructions) throw new Error("mergedInstructions is required."); - if (keepStepId === removeStepId) throw new Error("keepStepId and removeStepId must be different."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot consolidate steps in a terminal run (status: ${run.status}).`); - } - - const keepRow = getStepRow(keepStepId); - if (!keepRow || keepRow.run_id !== runId) throw new Error(`Keep step not found in run: ${keepStepId}`); - const keepStep = toStep(keepRow); - if (TERMINAL_STEP_STATUSES.has(keepStep.status)) { - throw new Error(`Keep step is already terminal (status: ${keepStep.status}).`); - } - - const removeRow = getStepRow(removeStepId); - if (!removeRow || removeRow.run_id !== runId) throw new Error(`Remove step not found in run: ${removeStepId}`); - const removeStep = toStep(removeRow); - if (TERMINAL_STEP_STATUSES.has(removeStep.status)) { - throw new Error(`Remove step is already terminal (status: ${removeStep.status}).`); - } - - const now = nowIso(); - - // Build key<->id maps from all steps in this run - const allStepRows = listStepRows(runId); - const allSteps = allStepRows.map(toStep); - const idToKey = new Map<string, string>(); - for (const s of allSteps) { - idToKey.set(s.id, s.stepKey); - } - - // 1. Skip the removeStep with a consolidation reason - db.run( - ` - update orchestrator_steps - set status = 'skipped', - updated_at = ?, - completed_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [now, now, removeStepId, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId: removeStepId, - eventType: "step_skipped", - reason: "consolidate_steps", - detail: { reason: `consolidated into ${keepStep.stepKey}` } - }); - emit({ type: "orchestrator-step-updated", runId, stepId: removeStepId, reason: "skipped" }); - - // 2. Update keepStep metadata with merged instructions - const keepMeta: Record<string, unknown> = parseJsonRecord(keepRow.metadata_json) ?? {}; - keepMeta.mergedInstructions = mergedInstructions; - keepMeta.consolidatedFrom = removeStep.stepKey; - db.run( - ` - update orchestrator_steps - set metadata_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(keepMeta), now, keepStepId, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId: keepStepId, - eventType: "step_consolidated", - reason: "consolidate_steps", - detail: { mergedInstructions, consolidatedFrom: removeStep.stepKey } - }); - emit({ type: "orchestrator-step-updated", runId, stepId: keepStepId, reason: "consolidated" }); - - // 3. Re-wire dependencies: any step that depended on removeStepId now depends on keepStepId - for (const s of allSteps) { - if (s.id === removeStepId || s.id === keepStepId) continue; - if (!s.dependencyStepIds.includes(removeStepId)) continue; - - const updatedDepIds = s.dependencyStepIds.map((depId) => - depId === removeStepId ? keepStepId : depId - ); - // De-duplicate in case the step already depended on keepStepId - const uniqueDepIds = [...new Set(updatedDepIds)]; - - db.run( - ` - update orchestrator_steps - set dependency_step_ids_json = ?, - updated_at = ? - where id = ? - and run_id = ? - and project_id = ? - `, - [JSON.stringify(uniqueDepIds), now, s.id, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId: s.id, - eventType: "step_dependencies_updated", - reason: "consolidate_steps", - detail: { - previousDependencyStepIds: s.dependencyStepIds, - updatedDependencyStepIds: uniqueDepIds, - rewiredFrom: removeStep.stepKey, - rewiredTo: keepStep.stepKey - } - }); - } - - // 4. Validate the full graph after all changes - const postAllSteps = listStepRows(runId).map(toStep); - const postIdToKey = new Map<string, string>(); - for (const s of postAllSteps) { - postIdToKey.set(s.id, s.stepKey); - } - const graphSteps: StepGraphValidationStep[] = postAllSteps - .filter((s) => !TERMINAL_STEP_STATUSES.has(s.status)) - .map((s) => ({ - stepKey: s.stepKey, - dependencyStepKeys: s.dependencyStepIds - .map((depId) => postIdToKey.get(depId) ?? "") - .filter((depKey): depKey is string => depKey.length > 0), - joinPolicy: s.joinPolicy, - quorumCount: s.quorumCount - })); - // Include terminal steps that are referenced as dependencies - const nonTerminalKeys = new Set(graphSteps.map((s) => s.stepKey)); - for (const s of postAllSteps) { - if (!nonTerminalKeys.has(s.stepKey)) { - graphSteps.push({ - stepKey: s.stepKey, - dependencyStepKeys: [], - joinPolicy: s.joinPolicy, - quorumCount: s.quorumCount - }); - } - } - validateStepGraphIntegrity({ - context: "consolidateSteps", - steps: graphSteps - }); - - // 5. Refresh readiness and re-evaluate run status - refreshStepReadiness(runId); - - const nextRunStatus = evaluateRunStatusWithPolicy(runId); - const currentRunStatus = normalizeRunStatus(runRow.status); - if (nextRunStatus !== currentRunStatus) { - updateRunStatus(runId, nextRunStatus); - } - - const resultRow = getStepRow(keepStepId); - if (!resultRow) throw new Error(`Keep step not found after consolidation: ${keepStepId}`); - return toStep(resultRow); - }, - - updateStepMetadata(args: { - runId: string; - stepId: string; - metadata: Record<string, unknown>; - retryLimit?: number | null; - allowTerminal?: boolean; - }): OrchestratorStep { - const runId = String(args.runId ?? "").trim(); - const stepId = String(args.stepId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!stepId) throw new Error("stepId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) { - throw new Error(`Cannot update step metadata in a terminal run (status: ${run.status}).`); - } - - const stepRow = getStepRow(stepId); - if (!stepRow || stepRow.run_id !== runId) throw new Error(`Step not found in run: ${stepId}`); - const step = toStep(stepRow); - if (!args.allowTerminal && TERMINAL_STEP_STATUSES.has(step.status)) { - throw new Error(`Step is already terminal (status: ${step.status}).`); - } - - const existingMeta = (step.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata)) ? step.metadata as Record<string, unknown> : {}; - const mergedMeta = normalizePlanningQuestionStepMetadata({ - missionId: run.missionId, - runMetadata: run.metadata, - metadata: { ...existingMeta, ...args.metadata }, - }); - const retryLimit = Number(args.retryLimit); - const hasRetryLimit = Number.isFinite(retryLimit) && retryLimit >= 0; - - const now = nowIso(); - db.run( - `update orchestrator_steps - set metadata_json = ?, - retry_limit = case when ? then ? else retry_limit end, - updated_at = ? - where id = ? and run_id = ? and project_id = ?`, - [ - JSON.stringify(mergedMeta), - hasRetryLimit ? 1 : 0, - hasRetryLimit ? Math.floor(retryLimit) : step.retryLimit, - now, - stepId, - runId, - projectId - ] - ); - - appendTimelineEvent({ - runId, - stepId, - eventType: "step_metadata_updated", - reason: "update_step_metadata", - detail: { - updatedKeys: Object.keys(args.metadata), - ...(hasRetryLimit ? { retryLimit: Math.floor(retryLimit) } : {}) - } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "metadata_updated" }); - - const updatedRow = getStepRow(stepId); - if (!updatedRow) throw new Error(`Step not found after metadata update: ${stepId}`); - return toStep(updatedRow); - }, - - updateStepExecutorKind(args: { - runId: string; - stepId: string; - executorKind: string; - }): OrchestratorStep { - const runId = String(args.runId ?? "").trim(); - const stepId = String(args.stepId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!stepId) throw new Error("stepId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - if (TERMINAL_RUN_STATUSES.has(toRun(runRow).status)) { - throw new Error(`Cannot update step in a terminal run.`); - } - - const stepRow = getStepRow(stepId); - if (!stepRow || stepRow.run_id !== runId) throw new Error(`Step not found in run: ${stepId}`); - if (TERMINAL_STEP_STATUSES.has(toStep(stepRow).status)) { - throw new Error(`Step is already terminal (status: ${toStep(stepRow).status}).`); - } - - const now = nowIso(); - db.run( - `update orchestrator_steps set executor_kind = ?, updated_at = ? where id = ? and run_id = ? and project_id = ?`, - [args.executorKind, now, stepId, runId, projectId] - ); - appendTimelineEvent({ - runId, - stepId, - eventType: "step_metadata_updated", - reason: "executor_kind_updated", - detail: { newExecutorKind: args.executorKind } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "executor_kind_updated" }); - - const result = getStepRow(stepId); - if (!result) throw new Error(`Step not found after executor kind update: ${stepId}`); - return toStep(result); - }, - - // ─── Recovery Loop ──────────────────────────────────────────── - - triggerRecoveryLoop(args: { - runId: string; - failedStepId: string; - failurePhase: string; - failureReason: string; - }): { action: "fix"; fixStepId: string; recheckStepId: string } | { action: "stop"; reason: string } { - const runId = String(args.runId ?? "").trim(); - const failedStepId = String(args.failedStepId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - if (!failedStepId) throw new Error("failedStepId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - - // Resolve recovery policy from run metadata or use default - let policy: RecoveryLoopPolicy = DEFAULT_RECOVERY_LOOP_POLICY; - if (runRow.metadata_json) { - try { - const meta = JSON.parse(runRow.metadata_json); - if (meta?.recoveryLoop && typeof meta.recoveryLoop === "object") { - policy = { ...DEFAULT_RECOVERY_LOOP_POLICY, ...meta.recoveryLoop }; - } - } catch { - // fall through to default - } - } - - if (!policy.enabled) { - return { action: "stop", reason: "Recovery loops are disabled by policy." }; - } - - // Get or create recovery loop state for this run - let state = recoveryLoopStates.get(runId); - if (!state) { - state = { - runId, - iterations: [], - currentIteration: 0, - exhausted: false, - stopReason: null - }; - recoveryLoopStates.set(runId, state); - } - - // Check if max iterations reached - if (state.currentIteration >= policy.maxIterations) { - state.exhausted = true; - state.stopReason = `Max recovery iterations reached (${policy.maxIterations}). onExhaustion=${policy.onExhaustion}`; - appendTimelineEvent({ - runId, - stepId: failedStepId, - eventType: "recovery_loop_exhausted", - reason: "max_iterations", - detail: { - maxIterations: policy.maxIterations, - onExhaustion: policy.onExhaustion, - failurePhase: args.failurePhase, - failureReason: args.failureReason - } - }); - return { action: "stop", reason: state.stopReason }; - } - - // Create new iteration - const iterationNum = state.currentIteration + 1; - const now = nowIso(); - - // Insert fix step - const fixStepId = randomUUID(); - const fixStepKey = `recovery_fix_${iterationNum}`; - const existingStepRows = listStepRows(runId); - const maxIndex = existingStepRows.reduce((max, row) => Math.max(max, row.step_index), -1); - - db.run( - ` - insert into orchestrator_steps( - id, run_id, project_id, mission_step_id, step_key, step_index, - title, lane_id, status, join_policy, quorum_count, - dependency_step_ids_json, retry_limit, retry_count, last_attempt_id, - policy_json, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, null, ?, ?, ?, null, 'pending', 'all_success', null, ?, 1, 0, null, '{}', ?, ?, ?, null, null) - `, - [ - fixStepId, - runId, - projectId, - fixStepKey, - maxIndex + 1, - `Recovery fix (iteration ${iterationNum})`, - JSON.stringify([failedStepId]), - JSON.stringify({ - stepType: "implementation", - recoveryIteration: iterationNum, - failedStepId, - failurePhase: args.failurePhase, - failureReason: args.failureReason, - instructions: `Fix the issues found in step ${failedStepId}: ${args.failureReason}` - }), - now, - now - ] - ); - - // Insert re-review step dependent on the fix step - const recheckStepId = randomUUID(); - const recheckStepKey = `recovery_recheck_${iterationNum}`; - - db.run( - ` - insert into orchestrator_steps( - id, run_id, project_id, mission_step_id, step_key, step_index, - title, lane_id, status, join_policy, quorum_count, - dependency_step_ids_json, retry_limit, retry_count, last_attempt_id, - policy_json, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, null, ?, ?, ?, null, 'pending', 'all_success', null, ?, 1, 0, null, '{}', ?, ?, ?, null, null) - `, - [ - recheckStepId, - runId, - projectId, - recheckStepKey, - maxIndex + 2, - `Recovery re-review (iteration ${iterationNum})`, - JSON.stringify([fixStepId]), - JSON.stringify({ - stepType: "code_review", - recoveryIteration: iterationNum, - instructions: `Re-review the fix applied in recovery iteration ${iterationNum}. Verify the original issue is resolved.` - }), - now, - now - ] - ); - - // Record the iteration - const iteration: RecoveryLoopIteration = { - iteration: iterationNum, - triggerStepId: failedStepId, - triggerPhase: args.failurePhase, - failureReason: args.failureReason, - fixStepId, - reReviewStepId: recheckStepId, - reTestStepId: null, - outcome: "still_failing", - startedAt: now, - completedAt: null - }; - state.iterations.push(iteration); - state.currentIteration = iterationNum; - - // Emit timeline events - appendTimelineEvent({ - runId, - stepId: failedStepId, - eventType: "recovery_loop_started", - reason: "quality_gate_failure", - detail: { - iteration: iterationNum, - maxIterations: policy.maxIterations, - failurePhase: args.failurePhase, - failureReason: args.failureReason, - fixStepId, - recheckStepId - } - }); - emit({ type: "orchestrator-step-updated", runId, stepId: fixStepId, reason: "recovery_fix_inserted" }); - emit({ type: "orchestrator-step-updated", runId, stepId: recheckStepId, reason: "recovery_recheck_inserted" }); - - refreshStepReadiness(runId); - - return { action: "fix", fixStepId, recheckStepId }; - }, - - // ─── Context View for Role ──────────────────────────────────── - - getContextViewForRole(args: { - role: OrchestratorWorkerRole; - }): ContextViewPolicy { - const role = args.role; - let viewKey: OrchestratorContextView; - switch (role) { - case "implementation": - case "planning": - case "integration": - viewKey = "implementation"; - break; - case "code_review": - viewKey = "review"; - break; - case "test_review": - case "testing": - viewKey = "test_review"; - break; - default: - viewKey = "implementation"; - break; - } - return DEFAULT_CONTEXT_VIEW_POLICIES[viewKey]; - }, - - // ─── Role Isolation Validation ──────────────────────────────── - - validateRunRoleIsolation(args: { - runId: string; - }): RoleIsolationValidation { - const runId = String(args.runId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - - const stepRows = listStepRows(runId); - const attemptRows = listAttemptRows(runId); - - // Group attempts by executor_session_id, collecting the role from step metadata - const rolesBySession = new Map<string, Set<OrchestratorWorkerRole>>(); - const stepIdsBySession = new Map<string, Set<string>>(); - - for (const attempt of attemptRows) { - const sessionId = attempt.executor_session_id; - if (!sessionId) continue; - - // Find the step for this attempt to determine its role - const stepRow = stepRows.find((s) => s.id === attempt.step_id); - if (!stepRow) continue; - - let role: OrchestratorWorkerRole | null = null; - if (stepRow.metadata_json) { - try { - const meta = JSON.parse(stepRow.metadata_json); - if (meta?.stepType) { - role = meta.stepType as OrchestratorWorkerRole; - } - if (meta?.role) { - role = meta.role as OrchestratorWorkerRole; - } - } catch { - // skip - } - } - if (!role) continue; - - if (!rolesBySession.has(sessionId)) { - rolesBySession.set(sessionId, new Set()); - stepIdsBySession.set(sessionId, new Set()); - } - rolesBySession.get(sessionId)!.add(role); - stepIdsBySession.get(sessionId)!.add(stepRow.id); - } - - // Check each session against isolation rules - const violations: RoleIsolationValidation["violations"] = []; - - for (const [sessionId, roles] of rolesBySession) { - for (const rule of DEFAULT_ROLE_ISOLATION_RULES) { - const [roleA, roleB] = rule.mutuallyExclusive; - if (roles.has(roleA) && roles.has(roleB)) { - const affectedStepIds = Array.from(stepIdsBySession.get(sessionId) ?? []); - violations.push({ - rule, - affectedStepIds, - correctionApplied: false, - correctionDetail: `Session ${sessionId} has conflicting roles: ${roleA} and ${roleB}.` - }); - } - } - } - - const result: RoleIsolationValidation = { - valid: violations.length === 0, - violations, - correctedPlan: false - }; - - if (violations.length > 0) { - appendTimelineEvent({ - runId, - eventType: "role_isolation_violation", - reason: "isolation_check", - detail: { - violationCount: violations.length, - violations: violations.map((v) => ({ - roles: v.rule.mutuallyExclusive, - enforcement: v.rule.enforcement, - affectedStepIds: v.affectedStepIds, - reason: v.rule.reason - })) - } - }); - } - - return result; - }, - - // ─── Worker Checkpoint Persistence ────────────────────────────── - - upsertWorkerCheckpoint(args: { - missionId: string; - runId: string; - stepId: string; - attemptId: string; - stepKey: string; - content: string; - filePath: string; - }): OrchestratorWorkerCheckpoint { - const now = nowIso(); - const existingRow = db.get<{ id: string }>( - `select id from orchestrator_worker_checkpoints where mission_id = ? and step_key = ? and project_id = ? limit 1`, - [args.missionId, args.stepKey, projectId] - ); - if (existingRow) { - db.run( - ` - update orchestrator_worker_checkpoints - set run_id = ?, - step_id = ?, - attempt_id = ?, - content = ?, - file_path = ?, - updated_at = ? - where id = ? - and project_id = ? - `, - [args.runId, args.stepId, args.attemptId, args.content, args.filePath, now, existingRow.id, projectId] - ); - return { - id: existingRow.id, - missionId: args.missionId, - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - stepKey: args.stepKey, - content: args.content, - filePath: args.filePath, - createdAt: now, - updatedAt: now - }; - } - const id = randomUUID(); - db.run( - ` - insert into orchestrator_worker_checkpoints( - id, project_id, mission_id, run_id, step_id, attempt_id, - step_key, content, file_path, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [id, projectId, args.missionId, args.runId, args.stepId, args.attemptId, args.stepKey, args.content, args.filePath, now, now] - ); - return { - id, - missionId: args.missionId, - runId: args.runId, - stepId: args.stepId, - attemptId: args.attemptId, - stepKey: args.stepKey, - content: args.content, - filePath: args.filePath, - createdAt: now, - updatedAt: now - }; - }, - - getWorkerCheckpoint(args: { missionId: string; stepKey: string }): OrchestratorWorkerCheckpoint | null { - const row = db.get<{ - id: string; - mission_id: string; - run_id: string; - step_id: string; - attempt_id: string; - step_key: string; - content: string; - file_path: string; - created_at: string; - updated_at: string; - }>( - ` - select id, mission_id, run_id, step_id, attempt_id, step_key, content, file_path, created_at, updated_at - from orchestrator_worker_checkpoints - where mission_id = ? and step_key = ? and project_id = ? - order by updated_at desc - limit 1 - `, - [args.missionId, args.stepKey, projectId] - ); - if (!row) return null; - return { - id: row.id, - missionId: row.mission_id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - stepKey: row.step_key, - content: row.content, - filePath: row.file_path, - createdAt: row.created_at, - updatedAt: row.updated_at - }; - }, - - getWorkerCheckpointsForMission(args: { missionId: string }): OrchestratorWorkerCheckpoint[] { - const rows = db.all<{ - id: string; - mission_id: string; - run_id: string; - step_id: string; - attempt_id: string; - step_key: string; - content: string; - file_path: string; - created_at: string; - updated_at: string; - }>( - ` - select id, mission_id, run_id, step_id, attempt_id, step_key, content, file_path, created_at, updated_at - from orchestrator_worker_checkpoints - where mission_id = ? and project_id = ? - order by updated_at desc - `, - [args.missionId, projectId] - ); - return rows.map((row) => ({ - id: row.id, - missionId: row.mission_id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - stepKey: row.step_key, - content: row.content, - filePath: row.file_path, - createdAt: row.created_at, - updatedAt: row.updated_at - })); - }, - - getWorkerCheckpointsForRun(args: { runId: string }): OrchestratorWorkerCheckpoint[] { - const rows = db.all<{ - id: string; - mission_id: string; - run_id: string; - step_id: string; - attempt_id: string; - step_key: string; - content: string; - file_path: string; - created_at: string; - updated_at: string; - }>( - ` - select id, mission_id, run_id, step_id, attempt_id, step_key, content, file_path, created_at, updated_at - from orchestrator_worker_checkpoints - where run_id = ? and project_id = ? - order by updated_at desc - `, - [args.runId, projectId] - ); - return rows.map((row) => ({ - id: row.id, - missionId: row.mission_id, - runId: row.run_id, - stepId: row.step_id, - attemptId: row.attempt_id, - stepKey: row.step_key, - content: row.content, - filePath: row.file_path, - createdAt: row.created_at, - updatedAt: row.updated_at - })); - }, - - // ─── Integration PR Trigger ─────────────────────────────────── - - triggerIntegrationPr(args: { - runId: string; - policy: IntegrationPrPolicy; - }): { stepId: string } | null { - const runId = String(args.runId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - - const policy = args.policy; - if (!policy.enabled) return null; - - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - - // Get all steps and collect unique lane IDs - const stepRows = listStepRows(runId); - const laneIds = new Set<string>(); - for (const row of stepRows) { - if (row.lane_id) laneIds.add(row.lane_id); - } - - // Only create integration step if multiple lanes are involved - if (laneIds.size < 2) return null; - - const now = nowIso(); - const stepId = randomUUID(); - const stepKey = "integration_pr"; - - // All existing non-skipped/non-canceled step IDs become dependencies - const allStepIds = stepRows - .filter((r) => r.status !== "skipped" && r.status !== "canceled") - .map((r) => r.id); - const maxIndex = stepRows.reduce((max, row) => Math.max(max, row.step_index), -1); - - db.run( - ` - insert into orchestrator_steps( - id, run_id, project_id, mission_step_id, step_key, step_index, - title, lane_id, status, join_policy, quorum_count, - dependency_step_ids_json, retry_limit, retry_count, last_attempt_id, - policy_json, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, null, ?, ?, ?, null, 'pending', 'all_success', null, ?, 1, 0, null, '{}', ?, ?, ?, null, null) - `, - [ - stepId, - runId, - projectId, - stepKey, - maxIndex + 1, - "Integration PR", - JSON.stringify(allStepIds), - JSON.stringify({ - stepType: "merge", - instructions: "Create integration PR merging all parallel lanes, resolve any merge conflicts first", - laneIds: Array.from(laneIds), - policy: { - createIntegrationLane: policy.createIntegrationLane, - prDepth: policy.prDepth, - draft: policy.draft, - baseBranch: policy.baseBranch ?? null - } - }), - now, - now - ] - ); - - appendTimelineEvent({ - runId, - stepId, - eventType: "integration_pr_step_inserted", - reason: "multi_lane_integration", - detail: { - laneIds: Array.from(laneIds), - dependencyCount: allStepIds.length, - policy: { - createIntegrationLane: policy.createIntegrationLane, - prDepth: policy.prDepth, - draft: policy.draft - } - } - }); - emit({ type: "orchestrator-step-updated", runId, stepId, reason: "integration_pr_inserted" }); - - refreshStepReadiness(runId); - - return { stepId }; - }, - - // ── Artifact Registry ────────────────────────────────────────────── - - registerArtifact(artifact: { - id?: string; - missionId: string; - runId: string; - stepId: string; - attemptId: string; - artifactKey: string; - kind: OrchestratorArtifactKind; - value: string; - metadata?: Record<string, unknown>; - declared?: boolean; - }): OrchestratorArtifact { - const id = artifact.id ?? randomUUID(); - const now = nowIso(); - db.run( - ` - insert or ignore into orchestrator_artifacts( - id, project_id, mission_id, run_id, step_id, attempt_id, - artifact_key, kind, value, metadata_json, declared, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - projectId, - artifact.missionId, - artifact.runId, - artifact.stepId, - artifact.attemptId, - artifact.artifactKey, - artifact.kind, - artifact.value, - JSON.stringify(artifact.metadata ?? {}), - artifact.declared ? 1 : 0, - now - ] - ); - return { - id, - missionId: artifact.missionId, - runId: artifact.runId, - stepId: artifact.stepId, - attemptId: artifact.attemptId, - artifactKey: artifact.artifactKey, - kind: artifact.kind, - value: artifact.value, - metadata: artifact.metadata ?? {}, - declared: artifact.declared ?? false, - createdAt: now - }; - }, - - getArtifactsForStep(stepId: string): OrchestratorArtifact[] { - const rows = db.all<ArtifactRow>( - ` - select id, project_id, mission_id, run_id, step_id, attempt_id, - artifact_key, kind, value, metadata_json, declared, created_at - from orchestrator_artifacts - where step_id = ? - and project_id = ? - order by created_at asc - `, - [stepId, projectId] - ); - return rows.map(toArtifact); - }, - - getArtifactsForMission(missionId: string): OrchestratorArtifact[] { - const rows = db.all<ArtifactRow>( - ` - select id, project_id, mission_id, run_id, step_id, attempt_id, - artifact_key, kind, value, metadata_json, declared, created_at - from orchestrator_artifacts - where mission_id = ? - and project_id = ? - order by created_at asc - `, - [missionId, projectId] - ); - return rows.map(toArtifact); - }, - - getArtifactsByKey(missionId: string, artifactKey: string): OrchestratorArtifact[] { - const rows = db.all<ArtifactRow>( - ` - select id, project_id, mission_id, run_id, step_id, attempt_id, - artifact_key, kind, value, metadata_json, declared, created_at - from orchestrator_artifacts - where mission_id = ? - and artifact_key = ? - and project_id = ? - order by created_at asc - `, - [missionId, artifactKey, projectId] - ); - return rows.map(toArtifact); - }, - - // ─── Explicit Completion: finalizeRun ───────────────────────────── - // - // This is the ONLY path to terminal success. tick() never closes runs. - // The coordinator requests completion → kernel validates → kernel finalizes. - - finalizeRun(args: FinalizeRunArgs): FinalizeRunResult { - const runId = String(args.runId ?? "").trim(); - if (!runId) throw new Error("runId is required."); - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - - // Already terminal — return current state - if (TERMINAL_RUN_STATUSES.has(run.status)) { - try { - void this.generateRunRetrospective({ runId }); - } catch { - // best-effort - } - return { - finalized: true, - blockers: [], - finalStatus: run.status - }; - } - - // Read run state to check completion_requested - const runState = this.getRunState(runId); - - // If not in "completing" status, transition to it first - if (run.status !== "completing") { - updateRunStatus(runId, "completing"); - appendTimelineEvent({ - runId, - eventType: "run_completing", - reason: "finalize_run_requested", - detail: { previousStatus: run.status, force: args.force ?? false } - }); - } - - // Run the completion validator - const steps = listStepRows(runId).map(toStep); - const attempts = listAttemptRows(runId).map(toAttempt); - const claims = db.all<ClaimRow>( - ` - select id, run_id, step_id, attempt_id, owner_id, scope_kind, - scope_value, state, acquired_at, heartbeat_at, expires_at, - released_at, policy_json, metadata_json - from orchestrator_claims - where project_id = ? and run_id = ? and state = 'active' - `, - [projectId, runId] - ).map(toClaim); - - // Fetch unresolved interventions for the run. - // Runtime events don't have a dedicated status column; treat each - // intervention_opened row as "open" unless a matching intervention_resolved - // event exists for the same step. - const interventionRows = db.all<{ status: string }>( - ` - select 'open' as status - from orchestrator_runtime_events e - where e.project_id = ? and e.run_id = ? and e.event_type = 'intervention_opened' - and not exists ( - select 1 from mission_interventions mi - where mi.project_id = e.project_id - and mi.id = json_extract(e.payload_json, '$.interventionId') - and mi.status in ('resolved', 'dismissed') - ) - and not exists ( - select 1 from orchestrator_runtime_events r - where r.project_id = e.project_id and r.run_id = e.run_id - and r.event_type = 'intervention_resolved' - and ( - ( - json_extract(e.payload_json, '$.interventionId') is not null - and json_extract(r.payload_json, '$.interventionId') = json_extract(e.payload_json, '$.interventionId') - ) - or ( - json_extract(e.payload_json, '$.interventionId') is null - and ( - r.step_id = e.step_id - or (r.step_id is null and e.step_id is null) - ) - ) - ) - ) - `, - [projectId, runId] - ); - - const validation = validateRunCompletion(run, steps, attempts, claims, runState, interventionRows); - const finalRunMeta = parseJsonRecord(runRow.metadata_json); - const finalPhases = resolveRunPhaseCardsFromMetadata(finalRunMeta); - const finalSettings = resolveRunMissionLevelSettingsFromMetadata(finalRunMeta); - const hasConfiguredPhaseEvaluation = Boolean(finalPhases && finalPhases.length > 0 && finalSettings); - - let evaluation: RunCompletionEvaluation; - if (finalPhases && finalPhases.length > 0 && finalSettings) { - evaluation = evaluateRunCompletionFromPhases(steps, finalPhases, finalSettings); - } else { - evaluation = evaluateRunCompletion( - steps, - finalRunMeta && isExecutionPolicyRecord(finalRunMeta.executionPolicy) - ? (finalRunMeta.executionPolicy as MissionExecutionPolicy) - : DEFAULT_EXECUTION_POLICY - ); - } - - const evaluationBlockers = evaluation.diagnostics - .filter((diagnostic) => diagnostic.blocking) - .filter((diagnostic) => hasConfiguredPhaseEvaluation || diagnostic.code === "required_validation_missing") - .map((diagnostic) => ({ - code: diagnostic.code, - message: diagnostic.message, - detail: diagnostic.details ?? null, - })); - const completionBlockers = [...(args.force ? [] : validation.blockers), ...evaluationBlockers]; - const completionReady = - (validation.canComplete || args.force) - && evaluationBlockers.length === 0 - && (!hasConfiguredPhaseEvaluation || evaluation.completionReady); - - if (!completionReady) { - const recoveryWorkPending = hasNonTerminalExecutionWork(steps); - const evaluationRecoveryRequired = evaluationBlockers.length > 0; - const nextStatus: OrchestratorRunStatus = - recoveryWorkPending || evaluationRecoveryRequired ? "active" : "completing"; - const currentRunStatus = normalizeRunStatus(getRunRow(runId)?.status ?? run.status); - if (nextStatus !== currentRunStatus) { - updateRunStatus(runId, nextStatus); - } - - // Store the validation error in run state - if (runState) { - this.upsertRunState(runId, { - ...runState, - lastValidationError: completionBlockers.map((b) => b.message).join("; "), - completionValidated: false - }); - } - - appendTimelineEvent({ - runId, - eventType: "run_completion_blocked", - reason: "completion_gates_failed", - detail: { - blockers: completionBlockers, - validatedAt: validation.validatedAt, - completionReady: evaluation.completionReady, - evaluationStatus: evaluation.status, - evaluationRecoveryRequired, - recoveryWorkPending, - nextStatus - } - }); - - if (nextStatus !== "completing") { - emit({ - type: "orchestrator-run-updated", - runId, - reason: "completion_recovery_active", - }); - } - - return { - finalized: false, - blockers: completionBlockers.map((b) => b.message), - finalStatus: nextStatus - }; - } - - // Determine final terminal status - let finalStatus: OrchestratorRunStatus; - if (hasConfiguredPhaseEvaluation) { - finalStatus = evaluation.status === "failed" ? "failed" : "succeeded"; - } else { - const allStepStatuses = filterExecutionSteps(steps).map((step) => step.status); - finalStatus = allStepStatuses.some((status) => status === "failed") ? "failed" : "succeeded"; - } - - // Persist completion diagnostics - const existingMeta = parseJsonRecord(runRow.metadata_json) ?? {}; - const finalizedAt = nowIso(); - const updatedMeta = { - ...existingMeta, - completionDiagnostics: evaluation.diagnostics, - completionRiskFactors: evaluation.riskFactors, - completionValidation: validation, - finalizedAt, - }; - updateRunStatus(runId, finalStatus, { - metadata_json: JSON.stringify(updatedMeta), - ...(finalStatus === "succeeded" ? { last_error: null } : {}) - }); - - // Update run state to mark completion validated - if (runState) { - this.upsertRunState(runId, { - ...runState, - phase: "done", - completionValidated: true, - lastValidationError: null - }); - } - - appendTimelineEvent({ - runId, - eventType: "run_finalized", - reason: "finalize_run", - detail: { - finalStatus, - forceRequested: args.force ?? false, - diagnosticCount: evaluation.diagnostics.length, - riskFactors: evaluation.riskFactors - } - }); - emit({ type: "orchestrator-run-updated", runId, reason: "finalized" }); - try { - void this.generateRunRetrospective({ runId }); - } catch { - // best-effort - } - - return { - finalized: true, - blockers: [], - finalStatus - }; - }, - - // ─── Status transitions ───────────────────────────────────────── - - /** Transition run from bootstrapping → active (planning done, coordinator online) */ - activateRun(runId: string): OrchestratorRun { - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (run.status !== "bootstrapping" && run.status !== "queued") { - throw new Error(`Cannot activate run in status '${run.status}' — must be 'bootstrapping' or 'queued'.`); - } - updateRunStatus(runId, "active"); - appendTimelineEvent({ - runId, - eventType: "run_activated", - reason: "activate_run", - detail: { previousStatus: run.status } - }); - emit({ type: "orchestrator-run-updated", runId, reason: "activated" }); - const updated = getRunRow(runId); - if (!updated) throw new Error(`Run not found after activation: ${runId}`); - return toRun(updated); - }, - - /** Transition run to "completing" when coordinator requests completion */ - requestCompletion(runId: string): OrchestratorRun { - const runRow = getRunRow(runId); - if (!runRow) throw new Error(`Run not found: ${runId}`); - const run = toRun(runRow); - if (TERMINAL_RUN_STATUSES.has(run.status)) return run; - if (run.status === "completing") return run; - updateRunStatus(runId, "completing"); - - // Mark completion as requested in run state - const runState = this.getRunState(runId); - if (runState) { - this.upsertRunState(runId, { ...runState, completionRequested: true }); - } - - appendTimelineEvent({ - runId, - eventType: "run_completion_requested", - reason: "request_completion", - detail: { previousStatus: run.status } - }); - emit({ type: "orchestrator-run-updated", runId, reason: "completion_requested" }); - const updated = getRunRow(runId); - if (!updated) throw new Error(`Run not found after requesting completion: ${runId}`); - return toRun(updated); - }, - - // ─── Run State CRUD (orchestrator_run_state) ────────────────────── - - upsertRunState(runId: string, state: Partial<OrchestratorTeamRuntimeState>): OrchestratorTeamRuntimeState { - const now = nowIso(); - const existing = this.getRunState(runId); - if (existing) { - const phase = state.phase ?? existing.phase; - const completionRequested = state.completionRequested ?? existing.completionRequested; - const completionValidated = state.completionValidated ?? existing.completionValidated; - const lastValidationError = state.lastValidationError !== undefined ? state.lastValidationError : existing.lastValidationError; - const coordinatorSessionId = state.coordinatorSessionId !== undefined ? state.coordinatorSessionId : existing.coordinatorSessionId; - const teammateIds = state.teammateIds ?? existing.teammateIds; - db.run( - ` - update orchestrator_run_state - set phase = ?, - completion_requested = ?, - completion_validated = ?, - last_validation_error = ?, - coordinator_session_id = ?, - teammate_ids_json = ?, - updated_at = ? - where run_id = ? - `, - [ - phase, - completionRequested ? 1 : 0, - completionValidated ? 1 : 0, - lastValidationError, - coordinatorSessionId, - JSON.stringify(teammateIds), - now, - runId - ] - ); - return { - runId, - phase, - completionRequested, - completionValidated, - lastValidationError, - coordinatorSessionId, - teammateIds, - createdAt: existing.createdAt, - updatedAt: now - }; - } else { - const phase = state.phase ?? "bootstrapping"; - const completionRequested = state.completionRequested ?? false; - const completionValidated = state.completionValidated ?? false; - const lastValidationError = state.lastValidationError ?? null; - const coordinatorSessionId = state.coordinatorSessionId ?? null; - const teammateIds = state.teammateIds ?? []; - db.run( - ` - insert into orchestrator_run_state( - run_id, phase, completion_requested, completion_validated, - last_validation_error, coordinator_session_id, teammate_ids_json, - created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - runId, - phase, - completionRequested ? 1 : 0, - completionValidated ? 1 : 0, - lastValidationError, - coordinatorSessionId, - JSON.stringify(teammateIds), - now, - now - ] - ); - return { - runId, - phase, - completionRequested, - completionValidated, - lastValidationError, - coordinatorSessionId, - teammateIds, - createdAt: now, - updatedAt: now - }; - } - }, - - getRunState(runId: string): OrchestratorTeamRuntimeState | null { - const row = db.get<{ - run_id: string; - phase: string; - completion_requested: number; - completion_validated: number; - last_validation_error: string | null; - coordinator_session_id: string | null; - teammate_ids_json: string; - created_at: string; - updated_at: string; - }>( - ` - select run_id, phase, completion_requested, completion_validated, - last_validation_error, coordinator_session_id, teammate_ids_json, - created_at, updated_at - from orchestrator_run_state - where run_id = ? - limit 1 - `, - [runId] - ); - if (!row) return null; - return { - runId: row.run_id, - phase: row.phase as OrchestratorTeamRuntimeState["phase"], - completionRequested: row.completion_requested === 1, - completionValidated: row.completion_validated === 1, - lastValidationError: row.last_validation_error, - coordinatorSessionId: row.coordinator_session_id, - teammateIds: parseArray(row.teammate_ids_json), - createdAt: row.created_at, - updatedAt: row.updated_at - }; - }, - - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/permissionMapping.ts b/apps/desktop/src/main/services/orchestrator/permissionMapping.ts deleted file mode 100644 index 46b24a8f8..000000000 --- a/apps/desktop/src/main/services/orchestrator/permissionMapping.ts +++ /dev/null @@ -1,284 +0,0 @@ -// --------------------------------------------------------------------------- -// Shared permission mapping utilities -// -// Extracts the mapping logic from agentChatService so both the chat pane -// and the orchestrator/mission system use identical permission semantics. -// --------------------------------------------------------------------------- - -import type { AgentChatPermissionMode } from "../../../shared/types/chat"; -import type { MissionProviderPermissions, MissionPermissionConfig } from "../../../shared/types/missions"; - -// ───────────────────────────────────────────────────── -// Chat-pane permission → CLI flag mapping -// ───────────────────────────────────────────────────── - -/** - * Map an AgentChatPermissionMode to the Claude CLI `--permission-mode` value - * (or `--dangerously-skip-permissions`). - */ -export function mapPermissionToClaude(mode: AgentChatPermissionMode | undefined): "default" | "plan" | "acceptEdits" | "bypassPermissions" { - if (mode === "full-auto") return "bypassPermissions"; - if (mode === "edit") return "acceptEdits"; - if (mode === "default") return "default"; - return "plan"; -} - -/** - * Map an AgentChatPermissionMode to Codex CLI approval-policy + sandbox. - * Returns null for "config-toml" (let Codex read its own config). - */ -export function mapPermissionToCodex(mode: AgentChatPermissionMode | undefined): { - approvalPolicy: "untrusted" | "on-request" | "on-failure" | "never"; - sandbox: "read-only" | "workspace-write" | "danger-full-access"; -} | null { - if (mode === "full-auto") { - return { approvalPolicy: "never", sandbox: "danger-full-access" }; - } - if (mode === "edit") { - return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; - } - if (mode === "config-toml") { - return null; - } - if (mode === "default") { - return { approvalPolicy: "on-request", sandbox: "workspace-write" }; - } - // "plan" / undefined → read-only browsing mode - return { approvalPolicy: "on-request", sandbox: "read-only" }; -} - -/** - * Map an AgentChatPermissionMode to the OpenCode-backed in-process permission - * mode string used by the native runtime path. - * - * The in-process executor uses `AgentPermissionMode` = "read-only" | "edit" | "full-auto". - * "plan" and "default" both map to "read-only" (no modifications allowed). - */ -export function mapPermissionToInProcess(mode: AgentChatPermissionMode | undefined): "read-only" | "edit" | "full-auto" { - if (mode === "full-auto") return "full-auto"; - if (mode === "edit") return "edit"; - // "default", "plan", "config-toml", undefined → read-only - return "read-only"; -} - -// ───────────────────────────────────────────────────── -// Old → new normalization (backward compat) -// ───────────────────────────────────────────────────── - -/** Valid old CLI modes */ -const VALID_CLI_MODES = new Set(["read-only", "edit", "full-auto"]); -/** Valid old in-process modes */ -const VALID_IN_PROCESS_MODES = new Set(["plan", "edit", "full-auto"]); -/** Valid new AgentChatPermissionMode values */ -const VALID_PROVIDER_MODES = new Set(["default", "plan", "edit", "full-auto", "config-toml"]); - -/** - * Map old CLI mode ("read-only" | "edit" | "full-auto") to AgentChatPermissionMode. - * "read-only" → "plan" (closest equivalent: suggest but don't change) - */ -function oldCliModeToProvider(mode: string | undefined): AgentChatPermissionMode { - if (mode === "read-only") return "plan"; - if (mode === "edit") return "edit"; - if (mode === "full-auto") return "full-auto"; - return "full-auto"; // default for CLI workers -} - -/** - * Map old in-process mode ("plan" | "edit" | "full-auto") to AgentChatPermissionMode. - */ -function oldInProcessModeToProvider(mode: string | undefined): AgentChatPermissionMode { - if (mode === "plan") return "plan"; - if (mode === "edit") return "edit"; - if (mode === "full-auto") return "full-auto"; - return "full-auto"; // default for in-process -} - -/** - * Normalize a MissionPermissionConfig (which may contain old `cli`/`inProcess` - * fields, new `providers` field, or both) into a canonical MissionProviderPermissions. - * - * Precedence: `providers` fields take priority over old `cli`/`inProcess` fields. - */ -export function normalizeMissionPermissions(config: MissionPermissionConfig | undefined): MissionProviderPermissions { - const result: MissionProviderPermissions = { - claude: "full-auto", - codex: "default", - droid: "full-auto", - opencode: "full-auto", - codexSandbox: "workspace-write", - }; - - // Layer 1: old shape - if (config?.cli) { - const cliMode = VALID_CLI_MODES.has(config.cli.mode ?? "") ? config.cli.mode : undefined; - const asProvider = oldCliModeToProvider(cliMode); - result.claude = asProvider; - result.codex = asProvider; - result.droid = asProvider; - if (config.cli.sandboxPermissions === "read-only" || config.cli.sandboxPermissions === "workspace-write" || config.cli.sandboxPermissions === "danger-full-access") { - result.codexSandbox = config.cli.sandboxPermissions; - } - if (Array.isArray(config.cli.writablePaths)) { - result.writablePaths = config.cli.writablePaths.filter((p): p is string => typeof p === "string" && p.trim().length > 0).map(p => p.trim()); - } - if (Array.isArray(config.cli.allowedTools)) { - result.allowedTools = config.cli.allowedTools.filter((t): t is string => typeof t === "string" && t.trim().length > 0).map(t => t.trim()); - } - } - if (config?.inProcess) { - const inProcMode = VALID_IN_PROCESS_MODES.has(config.inProcess.mode ?? "") ? config.inProcess.mode : undefined; - result.opencode = oldInProcessModeToProvider(inProcMode); - } - - // Layer 2: new provider shape (overrides old shape where present) - if (config?.providers) { - const p = config.providers; - if (p.claude && VALID_PROVIDER_MODES.has(p.claude)) result.claude = p.claude; - if (p.codex && VALID_PROVIDER_MODES.has(p.codex)) result.codex = p.codex; - if (p.cursor && VALID_PROVIDER_MODES.has(p.cursor)) result.cursor = p.cursor; - if (p.droid && VALID_PROVIDER_MODES.has(p.droid)) result.droid = p.droid; - if (p.opencode && VALID_PROVIDER_MODES.has(p.opencode)) result.opencode = p.opencode; - if (p.codexSandbox === "read-only" || p.codexSandbox === "workspace-write" || p.codexSandbox === "danger-full-access") { - result.codexSandbox = p.codexSandbox; - } - if (Array.isArray(p.writablePaths) && p.writablePaths.length > 0) { - result.writablePaths = p.writablePaths.filter((x): x is string => typeof x === "string" && x.trim().length > 0).map(x => x.trim()); - } - if (Array.isArray(p.allowedTools) && p.allowedTools.length > 0) { - result.allowedTools = p.allowedTools.filter((x): x is string => typeof x === "string" && x.trim().length > 0).map(x => x.trim()); - } - } - - return result; -} - -export function mergeMissionPermissionConfig( - base: MissionPermissionConfig | undefined, - override: MissionPermissionConfig | undefined, -): MissionPermissionConfig { - if (!base) return override ?? {}; - if (!override) return base; - const merged: MissionPermissionConfig = { - ...(base.cli || override.cli - ? { - cli: { - ...(base.cli ?? {}), - ...(override.cli ?? {}), - }, - } - : {}), - ...(base.inProcess || override.inProcess - ? { - inProcess: { - ...(base.inProcess ?? {}), - ...(override.inProcess ?? {}), - }, - } - : {}), - ...(base.providers || override.providers - ? { - providers: { - ...(base.providers ?? {}), - ...(override.providers ?? {}), - }, - } - : {}), - }; - const providerOverrides: Partial<MissionProviderPermissions> = {}; - const overrideCliMode = VALID_CLI_MODES.has(override.cli?.mode ?? "") ? override.cli?.mode : undefined; - if (overrideCliMode) { - const providerMode = oldCliModeToProvider(overrideCliMode); - providerOverrides.claude = providerMode; - providerOverrides.codex = providerMode; - providerOverrides.droid = providerMode; - } - const overrideInProcessMode = VALID_IN_PROCESS_MODES.has(override.inProcess?.mode ?? "") ? override.inProcess?.mode : undefined; - if (overrideInProcessMode) { - providerOverrides.opencode = oldInProcessModeToProvider(overrideInProcessMode); - } - if ( - override.cli?.sandboxPermissions === "read-only" - || override.cli?.sandboxPermissions === "workspace-write" - || override.cli?.sandboxPermissions === "danger-full-access" - ) { - providerOverrides.codexSandbox = override.cli.sandboxPermissions; - } - if (Array.isArray(override.cli?.writablePaths)) { - providerOverrides.writablePaths = override.cli.writablePaths; - } - if (Array.isArray(override.cli?.allowedTools)) { - providerOverrides.allowedTools = override.cli.allowedTools; - } - if (override.providers) { - Object.assign(providerOverrides, override.providers); - } - if (Object.keys(providerOverrides).length > 0) { - merged.providers = { - ...(base.providers ?? {}), - ...providerOverrides, - }; - } - return merged; -} - -// ───────────────────────────────────────────────────── -// Provider permissions → old-style permissionConfig -// (for passing into adapters that still use the old shape) -// ───────────────────────────────────────────────────── - -/** - * Convert AgentChatPermissionMode to old CLI mode. - * "default" / "plan" → "read-only", "config-toml" → "full-auto" (let CLI decide) - */ -function providerModeToCliMode(mode: AgentChatPermissionMode | undefined): "read-only" | "edit" | "full-auto" { - if (mode === "full-auto") return "full-auto"; - if (mode === "edit") return "edit"; - if (mode === "default" || mode === "plan") return "read-only"; - if (mode === "config-toml") return "full-auto"; - return "full-auto"; -} - -/** - * Convert AgentChatPermissionMode to old in-process mode. - */ -function providerModeToInProcessMode(mode: AgentChatPermissionMode | undefined): "plan" | "edit" | "full-auto" { - if (mode === "full-auto") return "full-auto"; - if (mode === "edit") return "edit"; - if (mode === "default" || mode === "plan" || mode === "config-toml") return "plan"; - return "full-auto"; -} - -/** - * Convert normalized MissionProviderPermissions into the old-style - * `permissionConfig` shape that OrchestratorExecutorStartArgs uses. - * This bridges the provider-level mission settings to the existing adapter interface. - */ -export function providerPermissionsToLegacyConfig(providers: MissionProviderPermissions): { - cli: { - mode: "read-only" | "edit" | "full-auto"; - sandboxPermissions: "read-only" | "workspace-write" | "danger-full-access"; - writablePaths?: string[]; - allowedTools?: string[]; - }; - inProcess: { - mode: "plan" | "edit" | "full-auto"; - }; - /** Carry the provider-level modes through for adapters that can use them directly. */ - _providers: MissionProviderPermissions; -} { - // For CLI config, use the claude mode as representative (most common CLI worker). - // The adapter will use _providers to pick the right one per model family. - const claudeCliMode = providerModeToCliMode(providers.claude); - return { - cli: { - mode: claudeCliMode, - sandboxPermissions: providers.codexSandbox ?? "workspace-write", - ...(providers.writablePaths?.length ? { writablePaths: providers.writablePaths } : {}), - ...(providers.allowedTools?.length ? { allowedTools: providers.allowedTools } : {}), - }, - inProcess: { - mode: providerModeToInProcessMode(providers.opencode), - }, - _providers: providers, - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/planningQuestionPolicy.ts b/apps/desktop/src/main/services/orchestrator/planningQuestionPolicy.ts deleted file mode 100644 index 2187eeeb8..000000000 --- a/apps/desktop/src/main/services/orchestrator/planningQuestionPolicy.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { MissionIntervention, PhaseCard } from "../../../shared/types"; - -type InterventionLike = { - status?: string | null; - interventionType?: string | null; - intervention_type?: string | null; - metadata?: Record<string, unknown> | null; -}; - -function normalize(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isPlanningPhase(phase: PhaseCard | null | undefined): boolean { - const phaseKey = normalize(phase?.phaseKey); - const phaseName = normalize(phase?.name); - return phaseKey === "planning" || phaseName === "planning"; -} - -function textRequiresPlanningQuestion(value: string): boolean { - const normalized = value.replace(/\s+/g, " ").trim().toLowerCase(); - if (!normalized) return false; - if (/\b(?:do not|don't|dont|no need to|without)\b.{0,60}\bask(?:s|ed|ing)?\b.{0,60}\b(?:clarification|question|questions|input)\b/.test(normalized)) { - return false; - } - const asksQuestion = /\bask(?:s|ed|ing)?\b.{0,90}\b(blocking\s+)?(?:clarification|clarifying|question|questions|input)\b/.test(normalized); - const beforeExecution = /\bbefore\b.{0,90}\b(?:planning|implementation|implementing|development|coding|execution)\b/.test(normalized); - const blockingQuestion = /\b(?:blocking|required|must)\b.{0,90}\b(?:clarification|question|questions|input)\b/.test(normalized); - const questionBeforeExecution = /\b(?:clarification|question|questions|input)\b.{0,90}\bbefore\b.{0,90}\b(?:planning|implementation|implementing|development|coding|execution)\b/.test(normalized); - return (asksQuestion && beforeExecution) || blockingQuestion || questionBeforeExecution; -} - -export function phaseRequiresPlanningQuestionBeforeExit(args: { - phase: PhaseCard | null | undefined; - missionPrompt?: string | null; -}): boolean { - const { phase } = args; - if (!isPlanningPhase(phase) || phase?.askQuestions?.enabled !== true) return false; - if (phase.askQuestions?.requiredBeforeExit === true) return true; - return textRequiresPlanningQuestion(`${phase.instructions ?? ""}\n${args.missionPrompt ?? ""}`); -} - -export function isPlanningQuestionIntervention( - intervention: InterventionLike | MissionIntervention, - args: { runId?: string | null } = {}, -): boolean { - const interventionType = normalize( - "interventionType" in intervention ? intervention.interventionType : intervention.intervention_type, - ); - if (interventionType && interventionType !== "manual_input") return false; - const metadata = intervention.metadata && typeof intervention.metadata === "object" - ? intervention.metadata - : null; - const source = normalize(metadata?.source); - const reasonCode = normalize(metadata?.reasonCode); - if ( - source !== "ask_user" - && source !== "request_user_input" - && reasonCode !== "planner_natural_question" - && reasonCode !== "planner_required_question_missing" - && reasonCode !== "planner_chat_question" - ) { - return false; - } - const runId = typeof args.runId === "string" ? args.runId.trim() : ""; - const interventionRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - if (runId && interventionRunId && interventionRunId !== runId) return false; - const phase = normalize(metadata?.phase); - const phaseKey = normalize(metadata?.phaseKey); - const phaseName = normalize(metadata?.phaseName); - const stepType = normalize(metadata?.stepType); - const ownerKind = normalize(metadata?.questionOwnerKind); - return reasonCode === "planner_natural_question" - || reasonCode === "planner_required_question_missing" - || reasonCode === "planner_chat_question" - || phase === "planning" - || phaseKey === "planning" - || phaseName === "planning" - || stepType === "planning" - || ownerKind === "planner"; -} - -export function hasResolvedPlanningQuestionIntervention( - interventions: Array<InterventionLike | MissionIntervention>, - args: { runId?: string | null } = {}, -): boolean { - return interventions.some((entry) => - normalize(entry.status) === "resolved" && isPlanningQuestionIntervention(entry, args) - ); -} diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts deleted file mode 100644 index ab6b98f18..000000000 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.test.ts +++ /dev/null @@ -1,548 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - OrchestratorAttempt, - OrchestratorContextSnapshot, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorStepStatus, - PhaseCard, -} from "../../../shared/types"; -import { - buildWorkerPromptInspector, - buildCoordinatorPromptInspector, - buildPlanningPromptPreview, -} from "./promptInspector"; - -function makeStep(overrides: Partial<OrchestratorStep> & { id: string }): OrchestratorStep { - return { - runId: "run-1", - missionStepId: null, - stepKey: overrides.stepKey ?? overrides.id, - title: overrides.title ?? "Test step", - status: ("pending" as OrchestratorStepStatus), - stepIndex: overrides.stepIndex ?? 0, - dependencyStepIds: [], - joinPolicy: "all_success", - quorumCount: null, - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - laneId: null, - metadata: overrides.metadata ?? null, - createdAt: "2026-03-25T00:00:00.000Z", - updatedAt: "2026-03-25T00:00:00.000Z", - startedAt: null, - completedAt: null, - ...overrides, - }; -} - -function makeAttempt(overrides: Partial<OrchestratorAttempt> & { id: string; stepId: string }): OrchestratorAttempt { - return { - runId: "run-1", - status: "queued", - executorKind: "opencode", - executorSessionId: null, - metadata: {}, - resultEnvelope: null, - errorMessage: null, - createdAt: "2026-03-25T00:00:00.000Z", - updatedAt: "2026-03-25T00:00:00.000Z", - startedAt: null, - completedAt: null, - ...overrides, - } as OrchestratorAttempt; -} - -function makeRunGraph(overrides: { - steps?: OrchestratorStep[]; - attempts?: OrchestratorAttempt[]; - contextSnapshots?: OrchestratorContextSnapshot[]; - metadata?: Record<string, unknown>; -} = {}): OrchestratorRunGraph { - return { - run: { - id: "run-1", - missionId: "mission-1", - status: "active", - metadata: { - missionGoal: "Build the test feature", - ...overrides.metadata, - }, - createdAt: "2026-03-25T00:00:00.000Z", - updatedAt: "2026-03-25T00:00:00.000Z", - startedAt: "2026-03-25T00:00:00.000Z", - completedAt: null, - } as unknown as OrchestratorRunGraph["run"], - steps: overrides.steps ?? [makeStep({ id: "step-1" })], - attempts: overrides.attempts ?? [], - claims: [], - contextSnapshots: overrides.contextSnapshots ?? [], - handoffs: [], - timeline: [], - }; -} - -function makePhaseCard(overrides: Partial<PhaseCard> = {}): PhaseCard { - return { - id: "phase-dev", - phaseKey: "development", - name: "Development", - description: "Implement features", - instructions: "Write clean code and tests.", - model: { modelId: "openai/gpt-5.4", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-25T00:00:00.000Z", - updatedAt: "2026-03-25T00:00:00.000Z", - ...overrides, - }; -} - -describe("buildWorkerPromptInspector", () => { - it("returns inspector for a basic step with base guidance and mission goal layers", () => { - const step = makeStep({ id: "step-1", title: "Implement auth" }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - expect(inspector.target).toBe("worker"); - expect(inspector.runId).toBe("run-1"); - expect(inspector.missionId).toBe("mission-1"); - expect(inspector.stepId).toBe("step-1"); - expect(inspector.title).toBe("Implement auth prompt"); - expect(inspector.layers.length).toBeGreaterThanOrEqual(2); - - const baseLayer = inspector.layers.find((l) => l.label === "Base worker system guidance"); - expect(baseLayer).toBeDefined(); - expect(baseLayer!.source).toBe("system_owned"); - expect(baseLayer!.editable).toBe(false); - expect(baseLayer!.text).toContain('step "Implement auth"'); - - const goalLayer = inspector.layers.find((l) => l.label === "Mission goal"); - expect(goalLayer).toBeDefined(); - expect(goalLayer!.text).toBe("Build the test feature"); - }); - - it("includes step and phase instructions when present in metadata", () => { - const step = makeStep({ - id: "step-1", - metadata: { - instructions: "Implement the auth module carefully.", - phaseInstructions: "Follow the development phase rules.", - phaseKey: "development", - phaseName: "Development", - }, - }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const stepLayer = inspector.layers.find((l) => l.label === "Step instructions"); - expect(stepLayer).toBeDefined(); - expect(stepLayer!.text).toBe("Implement the auth module carefully."); - - const phaseLayer = inspector.layers.find((l) => l.label === "Phase instructions"); - expect(phaseLayer).toBeDefined(); - expect(phaseLayer!.text).toBe("Follow the development phase rules."); - }); - - it("includes read-only overlay for readOnlyExecution", () => { - const step = makeStep({ - id: "step-1", - metadata: { readOnlyExecution: true }, - }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const overlayLayer = inspector.layers.find((l) => l.label === "Runtime overlays and constraints"); - expect(overlayLayer).toBeDefined(); - expect(overlayLayer!.text).toContain("READ-ONLY"); - }); - - it("includes file ownership overlay when filePatterns are present", () => { - const step = makeStep({ - id: "step-1", - metadata: { filePatterns: ["src/auth/**", "src/middleware/**"] }, - }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const overlayLayer = inspector.layers.find((l) => l.label === "Runtime overlays and constraints"); - expect(overlayLayer).toBeDefined(); - expect(overlayLayer!.text).toContain("File ownership fence: src/auth/**, src/middleware/**"); - }); - - it("includes handoff summaries in overlays", () => { - const step = makeStep({ - id: "step-1", - metadata: { - handoffSummaries: ["Auth module created", "Config updated"], - }, - }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const overlayLayer = inspector.layers.find((l) => l.label === "Runtime overlays and constraints"); - expect(overlayLayer).toBeDefined(); - expect(overlayLayer!.text).toContain("Auth module created"); - expect(overlayLayer!.text).toContain("Config updated"); - }); - - it("includes steering directives layer", () => { - const step = makeStep({ - id: "step-1", - metadata: { - steeringDirectives: [ - { directive: "Prioritize test coverage", priority: "must", targetStepKey: "step-2" }, - { directive: "Use TypeScript strict mode", priority: "suggestion" }, - ], - }, - }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const steeringLayer = inspector.layers.find((l) => l.label === "Operator steering"); - expect(steeringLayer).toBeDefined(); - expect(steeringLayer!.text).toContain("[must] Prioritize test coverage (target: step-2)"); - expect(steeringLayer!.text).toContain("[suggestion] Use TypeScript strict mode"); - }); - - it("includes recovery context from latest attempt error", () => { - const step = makeStep({ id: "step-1" }); - const attempt = makeAttempt({ - id: "attempt-1", - stepId: "step-1", - errorMessage: "Timeout after 5 minutes", - createdAt: "2026-03-25T01:00:00.000Z", - }); - const graph = makeRunGraph({ steps: [step], attempts: [attempt] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const overlayLayer = inspector.layers.find((l) => l.label === "Runtime overlays and constraints"); - expect(overlayLayer).toBeDefined(); - expect(overlayLayer!.text).toContain("Timeout after 5 minutes"); - }); - - it("includes context snapshot references", () => { - const step = makeStep({ id: "step-1" }); - const snapshot: OrchestratorContextSnapshot = { - id: "snap-1", - runId: "run-1", - stepId: "step-1", - attemptId: null, - snapshotType: "step", - contextProfile: "implementation", - cursor: { - projectPackKey: "project-pack-1", - projectPackVersionNumber: 3, - lanePackKey: "lane-pack-1", - lanePackVersionNumber: 1, - docs: [{ path: "docs/ARCHITECTURE.md" }], - contextSources: ["prd", "architecture"], - }, - createdAt: "2026-03-25T00:00:00.000Z", - } as OrchestratorContextSnapshot; - const graph = makeRunGraph({ steps: [step], contextSnapshots: [snapshot] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const contextLayer = inspector.layers.find((l) => l.label === "Runtime context references"); - expect(contextLayer).toBeDefined(); - expect(contextLayer!.text).toContain("project-pack-1"); - expect(contextLayer!.text).toContain("lane-pack-1"); - expect(contextLayer!.text).toContain("docs/ARCHITECTURE.md"); - }); - - it("throws when step is not found", () => { - const graph = makeRunGraph({ steps: [] }); - expect(() => buildWorkerPromptInspector({ graph, stepId: "missing-step" })).toThrow( - "Step not found for prompt inspector", - ); - }); - - it("generates fullPrompt as concatenation of all layers", () => { - const step = makeStep({ id: "step-1", title: "Auth worker" }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - for (const layer of inspector.layers) { - expect(inspector.fullPrompt).toContain(`## ${layer.label}`); - expect(inspector.fullPrompt).toContain(layer.text.slice(0, 50)); - } - }); - - it("assigns sequential layer IDs", () => { - const step = makeStep({ - id: "step-1", - metadata: { - instructions: "Do X", - phaseInstructions: "Phase rules", - phaseKey: "development", - phaseName: "Development", - }, - }); - const graph = makeRunGraph({ steps: [step] }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - for (let i = 0; i < inspector.layers.length; i++) { - expect(inspector.layers[i].id).toBe(`layer-${i + 1}`); - } - }); - - it("includes team runtime overlay when enabled", () => { - const step = makeStep({ id: "step-1" }); - const graph = makeRunGraph({ - steps: [step], - metadata: { - missionGoal: "Build X", - teamRuntime: { enabled: true }, - }, - }); - - const inspector = buildWorkerPromptInspector({ graph, stepId: "step-1" }); - - const overlayLayer = inspector.layers.find((l) => l.label === "Runtime overlays and constraints"); - expect(overlayLayer).toBeDefined(); - expect(overlayLayer!.text).toContain("TEAM RUNTIME (ACTIVE)"); - }); -}); - -describe("buildCoordinatorPromptInspector", () => { - it("returns inspector with identity, protocol, and guardrail layers", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build feature X", - }); - - expect(inspector.target).toBe("coordinator"); - expect(inspector.runId).toBe("run-1"); - expect(inspector.missionId).toBe("mission-1"); - expect(inspector.title).toBe("Coordinator prompt"); - - const identityLayer = inspector.layers.find((l) => l.label === "Coordinator identity and autonomy contract"); - expect(identityLayer).toBeDefined(); - expect(identityLayer!.text).toContain("Build feature X"); - expect(identityLayer!.text).toContain("team lead"); - - const protocolLayer = inspector.layers.find((l) => l.label === "Planning and validation protocol"); - expect(protocolLayer).toBeDefined(); - expect(protocolLayer!.text).toContain("Phase ordering is enforced"); - - const guardrailLayer = inspector.layers.find((l) => l.label === "Lane and delegation guardrails"); - expect(guardrailLayer).toBeDefined(); - }); - - it("includes user rules layer when rules are provided", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - userRules: { - providerPreference: "claude", - costMode: "balanced", - maxParallelWorkers: 4, - allowParallelAgents: true, - allowSubAgents: false, - laneStrategy: "single", - customInstructions: "Always run tests before committing", - coordinatorModel: "anthropic/claude-sonnet-4-6", - budgetLimitUsd: 10.5, - }, - }); - - const rulesLayer = inspector.layers.find((l) => l.label === "User-configured rules"); - expect(rulesLayer).toBeDefined(); - expect(rulesLayer!.text).toContain("Provider preference: claude"); - expect(rulesLayer!.text).toContain("Cost mode: balanced"); - expect(rulesLayer!.text).toContain("Maximum parallel workers: 4"); - expect(rulesLayer!.text).toContain("Parallel agents: enabled"); - expect(rulesLayer!.text).toContain("Sub-agents: disabled"); - expect(rulesLayer!.text).toContain("Always run tests before committing"); - expect(rulesLayer!.text).toContain("$10.50 USD"); - }); - - it("includes phase configuration snapshot", () => { - const phases = [ - makePhaseCard({ phaseKey: "planning", name: "Planning", position: 0, isCustom: false }), - makePhaseCard({ phaseKey: "development", name: "Development", position: 1, isCustom: true }), - ]; - - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - phases, - }); - - const phasesLayer = inspector.layers.find((l) => l.label === "Phase configuration snapshot"); - expect(phasesLayer).toBeDefined(); - expect(phasesLayer!.text).toContain("PLANNING"); - expect(phasesLayer!.text).toContain("[CUSTOM] DEVELOPMENT"); - }); - - it("includes available providers section", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - availableProviders: [ - { name: "claude", available: true }, - { name: "codex", available: true }, - { name: "local-ollama", available: false }, - ], - }); - - const providersLayer = inspector.layers.find((l) => l.label === "Available worker/runtime surface"); - expect(providersLayer).toBeDefined(); - expect(providersLayer!.text).toContain("claude, codex"); - expect(providersLayer!.text).not.toContain("local-ollama"); - }); - - it("handles no available providers gracefully", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - }); - - const providersLayer = inspector.layers.find((l) => l.label === "Available worker/runtime surface"); - expect(providersLayer).toBeDefined(); - expect(providersLayer!.text).toContain("provider availability was not persisted"); - }); - - it("includes project context section", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - projectContext: { - projectRoot: "/Users/dev/myproject", - projectDocPaths: ["docs/ARCHITECTURE.md", "docs/PRD.md"], - projectHints: ["Uses TypeScript with strict mode"], - fileTree: "src/\n main/\n renderer/", - }, - }); - - const contextLayer = inspector.layers.find((l) => l.label === "Project context"); - expect(contextLayer).toBeDefined(); - expect(contextLayer!.text).toContain("/Users/dev/myproject"); - expect(contextLayer!.text).toContain("docs/ARCHITECTURE.md"); - expect(contextLayer!.text).toContain("Uses TypeScript with strict mode"); - expect(contextLayer!.text).toContain("src/\n main/"); - }); - - it("populates phaseKey and phaseName from args", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - currentPhaseKey: "development", - currentPhaseName: "Development", - }); - - expect(inspector.phaseKey).toBe("development"); - expect(inspector.phaseName).toBe("Development"); - }); - - it("defaults phaseKey and phaseName to null", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - }); - - expect(inspector.phaseKey).toBeNull(); - expect(inspector.phaseName).toBeNull(); - }); - - it("includes notes about hidden coordinator behavior", () => { - const inspector = buildCoordinatorPromptInspector({ - runId: "run-1", - missionId: "mission-1", - missionGoal: "Build X", - }); - - expect(inspector.notes.length).toBeGreaterThanOrEqual(1); - expect(inspector.notes.some((n) => n.includes("hidden planning"))).toBe(true); - }); -}); - -describe("buildPlanningPromptPreview", () => { - it("returns a preview inspector with a single composed prompt layer", () => { - const phase = makePhaseCard({ - phaseKey: "planning", - name: "Planning", - instructions: "Research and plan carefully.", - model: { modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - }); - - const inspector = buildPlanningPromptPreview({ - missionPrompt: "Implement authentication module", - phase, - phases: [phase, makePhaseCard()], - }); - - expect(inspector.target).toBe("worker"); - expect(inspector.runId).toBe("preview-run"); - expect(inspector.missionId).toBe("preview-mission"); - expect(inspector.phaseKey).toBe("planning"); - expect(inspector.phaseName).toBe("Planning"); - expect(inspector.title).toBe("Planning worker prompt"); - expect(inspector.layers).toHaveLength(1); - expect(inspector.layers[0].label).toBe("Exact composed planner prompt"); - expect(inspector.fullPrompt).toContain("Implement authentication module"); - }); - - it("uses provided runId and missionId when available", () => { - const phase = makePhaseCard({ phaseKey: "planning", name: "Planning" }); - - const inspector = buildPlanningPromptPreview({ - missionPrompt: "Build X", - phase, - phases: [phase], - runId: "custom-run", - missionId: "custom-mission", - }); - - expect(inspector.runId).toBe("custom-run"); - expect(inspector.missionId).toBe("custom-mission"); - }); - - it("includes notes about read-only preview", () => { - const phase = makePhaseCard({ phaseKey: "planning", name: "Planning" }); - - const inspector = buildPlanningPromptPreview({ - missionPrompt: "Build X", - phase, - phases: [phase], - }); - - expect(inspector.notes.some((n) => n.includes("Read-only"))).toBe(true); - }); - - it("trims the mission prompt", () => { - const phase = makePhaseCard({ phaseKey: "planning", name: "Planning" }); - - const inspector = buildPlanningPromptPreview({ - missionPrompt: " Build X ", - phase, - phases: [phase], - }); - - expect(inspector.fullPrompt).toContain("Build X"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts deleted file mode 100644 index 1a9dfa74d..000000000 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.ts +++ /dev/null @@ -1,697 +0,0 @@ -import { - compactText, - buildCompactPlanView, - buildFullPrompt, -} from "./baseOrchestratorAdapter"; -import { - DEFAULT_CONTEXT_VIEW_POLICIES, - SLASH_COMMAND_TRANSLATIONS, -} from "./orchestratorConstants"; -import type { - CoordinatorAvailableProvider, - CoordinatorProjectContext, - CoordinatorUserRules, -} from "./coordinatorAgent"; -import type { - OrchestratorAttempt, - OrchestratorContextSnapshot, - GetPlanningPromptPreviewArgs, - OrchestratorPromptInspector, - OrchestratorPromptLayer, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorStepStatus, - OrchestratorWorkerRole, - PhaseCard, - TeamRuntimeConfig, -} from "../../../shared/types"; -import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; -import { isRecord, toOptionalString } from "../shared/utils"; - -function pushLayer( - layers: OrchestratorPromptLayer[], - layer: Omit<OrchestratorPromptLayer, "id">, -): void { - layers.push({ - id: `layer-${layers.length + 1}`, - ...layer, - }); -} - -function formatContextSnapshotLayer(snapshot: OrchestratorContextSnapshot | null): string | null { - if (!snapshot) return null; - const cursor = snapshot.cursor; - const lines: string[] = [ - `Snapshot type: ${snapshot.snapshotType}`, - `Context profile: ${snapshot.contextProfile}`, - ]; - if (cursor.projectPackKey) { - lines.push( - `Project export: ${cursor.projectPackKey}${cursor.projectPackVersionNumber != null ? ` v${cursor.projectPackVersionNumber}` : ""}`, - ); - } - if (cursor.lanePackKey) { - lines.push( - `Lane export: ${cursor.lanePackKey}${cursor.lanePackVersionNumber != null ? ` v${cursor.lanePackVersionNumber}` : ""}`, - ); - } - if (Array.isArray(cursor.docs) && cursor.docs.length > 0) { - lines.push(`Docs refs: ${cursor.docs.map((doc) => doc.path).join(", ")}`); - } - if (Array.isArray(cursor.contextSources) && cursor.contextSources.length > 0) { - lines.push(`Context sources: ${cursor.contextSources.join(", ")}`); - } - return lines.join("\n"); -} - -function buildExactWorkerPrompt(args: { - runId: string; - missionId: string; - missionGoal: string; - phase: PhaseCard; -}): string { - const syntheticRun = { - id: args.runId, - missionId: args.missionId, - status: "queued", - metadata: { - missionGoal: args.missionGoal, - phaseRuntime: { - currentPhaseKey: args.phase.phaseKey, - currentPhaseName: args.phase.name, - currentPhaseInstructions: args.phase.instructions, - currentPhaseModel: args.phase.model, - currentPhaseValidation: args.phase.validationGate, - }, - }, - } as unknown as OrchestratorRunGraph["run"]; - const syntheticStep = { - id: `preview-step:${args.phase.phaseKey}`, - stepKey: `planning_${args.phase.phaseKey}`, - title: `${args.phase.name} worker`, - status: "pending" as OrchestratorStepStatus, - stepIndex: 0, - laneId: null, - dependencyStepIds: [], - joinPolicy: "blocking", - metadata: { - role: "planner", - modelId: args.phase.model.modelId, - reasoningEffort: args.phase.model.thinkingLevel, - instructions: args.phase.instructions, - phaseInstructions: args.phase.instructions, - phaseKey: args.phase.phaseKey, - phaseName: args.phase.name, - phaseModel: args.phase.model, - phaseValidation: args.phase.validationGate, - readOnlyExecution: true, - stepType: "planning", - taskType: "planning", - }, - } as unknown as OrchestratorStep; - const syntheticAttempt = { - id: `preview-attempt:${args.phase.phaseKey}`, - runId: args.runId, - stepId: syntheticStep.id, - status: "queued", - executorKind: "opencode", - executorSessionId: null, - metadata: {}, - } as unknown as OrchestratorAttempt; - - return buildFullPrompt({ - run: syntheticRun, - step: syntheticStep, - attempt: syntheticAttempt, - allSteps: [syntheticStep], - contextProfile: null as never, - laneExport: null, - projectExport: {} as never, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "preview", sessionId: "preview" }), - }).prompt; -} - -function findLatestAttemptForStep(graph: OrchestratorRunGraph, stepId: string): OrchestratorAttempt | null { - const attempts = graph.attempts - .filter((attempt) => attempt.stepId === stepId) - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); - return attempts[0] ?? null; -} - -function findBestSnapshot(graph: OrchestratorRunGraph, stepId: string, attemptId: string | null): OrchestratorContextSnapshot | null { - if (attemptId) { - const attemptSnapshot = graph.contextSnapshots.find( - (snapshot) => snapshot.attemptId === attemptId, - ); - if (attemptSnapshot) return attemptSnapshot; - } - const stepSnapshot = graph.contextSnapshots.find( - (snapshot) => snapshot.stepId === stepId && snapshot.snapshotType !== "run", - ); - if (stepSnapshot) return stepSnapshot; - return graph.contextSnapshots.find((snapshot) => snapshot.snapshotType === "run") ?? null; -} - -function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunGraph): string { - const run = graph.run; - const missionGoal = - typeof run.metadata?.missionGoal === "string" ? run.metadata.missionGoal.trim() : ""; - const role = typeof step.metadata?.role === "string" ? step.metadata.role : step.stepKey; - const laneLabel = step.laneId ?? "unassigned"; - const sections: string[] = []; - sections.push(`You are an ADE orchestrator worker executing step "${step.title}".`); - sections.push( - [ - `Role: ${role}`, - `Step: "${step.title}" (key: ${step.stepKey})`, - `Mission: ${missionGoal || "(no goal)"} (mission: ${run.missionId}, run: ${run.id})`, - `Lane: ${laneLabel}`, - ].join("\n"), - ); - sections.push( - "EXECUTION PROTOCOL: Execute immediately. Do not ask for confirmation or propose a plan and wait for approval. Do not summarize your instructions back. If you encounter a blocker you cannot work around, fail with a clear error message describing the blocker. Never wait for human input — make the best decision you can and document your reasoning.", - ); - const planView = buildCompactPlanView(step, graph.steps); - if (planView) sections.push(planView); - sections.push( - [ - ADE_CLI_AGENT_GUIDANCE, - "", - "Work style:", - "- If you discover information relevant to other steps (API changes, schema updates, config requirements), include it in your output summary.", - "- If you hit a blocker you can work around safely, work around it and note what you did.", - "- Structure your output: lead with what you accomplished, then what you changed, then risks or notes for downstream steps.", - "- If your step depends on upstream work, check the handoff context before starting — don't redo completed work.", - ].join("\n"), - ); - sections.push( - [ - "COMMUNICATION STYLE:", - "You are part of a team. When you make progress, share brief updates in natural, casual English.", - "Write like a teammate in a Slack channel — short blurbs, not formal reports.", - 'Examples of good updates: "looking at the existing code first to understand the patterns"', - 'Examples of good updates: "implementing the auth middleware now, using JWT approach"', - 'Examples of good updates: "tests passing, moving on to the edge cases"', - 'Examples of good updates: "hit an issue with the import path, working around it"', - 'Examples of good updates: "done — changed 3 files, all tests green"', - "DO NOT dump full file contents, raw errors, or tool output into your updates.", - "Keep each update to 1-2 sentences max.", - ].join("\n"), - ); - sections.push( - [ - "RESULT REPORTING:", - "- Use `report_status` for short progress updates when you make meaningful progress or hit a blocker.", - "- Before you exit, ALWAYS call `report_result` with your outcome, summary, filesChanged, and testsRun fields filled in as accurately as possible.", - ].join("\n"), - ); - sections.push( - "You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions.", - "Project orientation: do not assume generated PRD or architecture summaries exist. When you need product or architecture context, inspect the repo directly: start with AGENTS.md, README.md, docs/, package manifests, and the relevant source files in your lane worktree.", - ); - sections.push( - [ - "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", - "Key actions available:", - "- get_worker_states", - "- get_run_graph", - "- get_mission", - "- get_pending_messages", - "- get_timeline", - "- stream_events", - ].join("\n"), - ); - sections.push( - [ - "Before finishing, write a HANDOFF SUMMARY (3-5 bullets):", - "1. What you accomplished (files created/modified)", - "2. What downstream steps need to know (API changes, new deps, config updates)", - "3. Any risks or known issues (edge cases not covered, flaky tests)", - ].join("\n"), - ); - return sections.join("\n\n"); -} - -export function buildWorkerPromptInspector(args: { - graph: OrchestratorRunGraph; - stepId: string; -}): OrchestratorPromptInspector { - const { graph, stepId } = args; - const step = graph.steps.find((entry) => entry.id === stepId); - if (!step) { - throw new Error(`Step not found for prompt inspector: ${stepId}`); - } - const latestAttempt = findLatestAttemptForStep(graph, step.id); - const latestSnapshot = findBestSnapshot(graph, step.id, latestAttempt?.id ?? null); - const run = graph.run; - const phaseKey = toOptionalString(step.metadata?.phaseKey) - ?? toOptionalString(run.metadata?.phaseRuntime && isRecord(run.metadata.phaseRuntime) ? run.metadata.phaseRuntime.currentPhaseKey : null); - const phaseName = toOptionalString(step.metadata?.phaseName) - ?? toOptionalString(run.metadata?.phaseRuntime && isRecord(run.metadata.phaseRuntime) ? run.metadata.phaseRuntime.currentPhaseName : null); - const missionGoal = - typeof run.metadata?.missionGoal === "string" ? run.metadata.missionGoal.trim() : ""; - - const layers: OrchestratorPromptLayer[] = []; - const notes = [ - "Phase instructions are only one layer. Worker behavior is still shaped by system-owned guidance, step runtime metadata, overlays, steering, and context snapshots.", - "Context export bodies are not persisted verbatim in the run graph. This inspector shows the runtime context references that were persisted with the attempt.", - ]; - - pushLayer(layers, { - label: "Base worker system guidance", - source: "system_owned", - sourceKind: "system_owned", - editable: false, - text: buildWorkerBaseGuidance(step, graph), - description: "System-owned worker contract from ADE. Editing phase text does not replace this layer.", - }); - - if (missionGoal.length > 0) { - pushLayer(layers, { - label: "Mission goal", - source: "mission_goal", - sourceKind: "run_snapshot", - editable: false, - text: missionGoal, - description: "Mission goal frozen onto the run.", - }); - } - - const stepInstructions = toOptionalString(step.metadata?.instructions); - if (stepInstructions) { - pushLayer(layers, { - label: "Step instructions", - source: "step_runtime", - sourceKind: "run_snapshot", - editable: false, - text: stepInstructions, - description: "Step-level runtime framing for this executable step.", - }); - } - - const phaseInstructions = toOptionalString(step.metadata?.phaseInstructions); - if (phaseInstructions) { - pushLayer(layers, { - label: "Phase instructions", - source: "phase_snapshot", - sourceKind: "run_snapshot", - editable: false, - text: phaseInstructions, - description: "Frozen phase-card instructions captured on the run snapshot.", - }); - } - - const runtimeOverlays: string[] = []; - const requiresPlanApproval = - step.metadata?.requiresPlanApproval === true || step.metadata?.coordinationPattern === "plan_then_implement"; - const readOnlyExecution = step.metadata?.readOnlyExecution === true || requiresPlanApproval; - if (readOnlyExecution) { - runtimeOverlays.push("IMPORTANT: This step is READ-ONLY. Do NOT modify files, stage changes, or run write operations. Research, review, and return findings or a plan only."); - } - - const filePatternsFromMetadata = Array.isArray(step.metadata?.filePatterns) - ? step.metadata.filePatterns.map((entry) => String(entry ?? "").trim()).filter(Boolean) - : []; - if (filePatternsFromMetadata.length > 0) { - runtimeOverlays.push(`File ownership fence: ${filePatternsFromMetadata.join(", ")}`); - } - - const handoffSummaries = Array.isArray(step.metadata?.handoffSummaries) - ? step.metadata.handoffSummaries.map((entry) => String(entry ?? "").trim()).filter(Boolean) - : []; - if (handoffSummaries.length > 0) { - runtimeOverlays.push(`Handoff context:\n${handoffSummaries.map((entry) => `- ${entry}`).join("\n")}`); - } - - const rawStartup = toOptionalString(step.metadata?.startupCommand); - const slashBase = rawStartup ? rawStartup.split(/\s/)[0] : null; - const slashTranslation = slashBase ? SLASH_COMMAND_TRANSLATIONS[slashBase] : undefined; - if (slashTranslation?.prompt) { - runtimeOverlays.push(`Slash command instructions:\n${slashTranslation.prompt}`); - } - - const workerRole = toOptionalString(step.metadata?.role) as OrchestratorWorkerRole | null; - const contextView = workerRole - ? DEFAULT_CONTEXT_VIEW_POLICIES[ - workerRole === "code_review" ? "review" : workerRole === "test_review" ? "test_review" : "implementation" - ] - : null; - if (contextView?.readOnly) { - runtimeOverlays.push("IMPORTANT: You are in a READ-ONLY review role. Do NOT modify any files. Only analyze and provide feedback on the code/tests you are reviewing."); - } - - const teamRuntime = isRecord(run.metadata) ? (run.metadata.teamRuntime as TeamRuntimeConfig | undefined) : undefined; - if (teamRuntime?.enabled) { - runtimeOverlays.push( - [ - "TEAM RUNTIME (ACTIVE): You are part of an ADE agent team with shared task management.", - "- You can claim tasks from the shared task list", - "- You can send messages to other teammates via the coordinator", - "- You can report progress, blockers, and discoveries", - ].join("\n"), - ); - } - - if (latestAttempt && (latestAttempt.errorMessage || latestAttempt.resultEnvelope?.summary || latestSnapshot)) { - const recoveryParts: string[] = []; - if (latestAttempt.errorMessage) { - recoveryParts.push(`Latest attempt error: ${latestAttempt.errorMessage}`); - } - if (latestAttempt.resultEnvelope?.summary) { - recoveryParts.push(`Latest attempt summary: ${latestAttempt.resultEnvelope.summary}`); - } - const checkpointPreview = toOptionalString(step.metadata?.lastCheckpointSummary); - if (checkpointPreview) { - recoveryParts.push(`Checkpoint summary: ${compactText(checkpointPreview, 400)}`); - } - if (recoveryParts.length > 0) { - runtimeOverlays.push(`Recovery / continuity context:\n${recoveryParts.join("\n")}`); - } - } - - if (runtimeOverlays.length > 0) { - pushLayer(layers, { - label: "Runtime overlays and constraints", - source: "runtime_overlay", - sourceKind: "live_effective_prompt", - editable: false, - text: runtimeOverlays.join("\n\n"), - description: "Read-only, review, ownership, recovery, and team-runtime overlays applied at execution time.", - }); - } - - const steeringDirectives = Array.isArray(step.metadata?.steeringDirectives) - ? step.metadata.steeringDirectives - .map((entry) => { - if (!isRecord(entry)) return null; - const directive = toOptionalString(entry.directive); - if (!directive) return null; - const priority = toOptionalString(entry.priority) ?? "suggestion"; - const targetStepKey = toOptionalString(entry.targetStepKey); - return `- [${priority}] ${directive}${targetStepKey ? ` (target: ${targetStepKey})` : ""}`; - }) - .filter((entry): entry is string => Boolean(entry)) - : []; - if (steeringDirectives.length > 0) { - pushLayer(layers, { - label: "Operator steering", - source: "user_steering", - sourceKind: "live_effective_prompt", - editable: false, - text: steeringDirectives.join("\n"), - description: "Live operator directives layered on top of the frozen run snapshot.", - }); - } - - const runtimeContextSections: string[] = []; - const contextLayer = formatContextSnapshotLayer(latestSnapshot); - if (contextLayer) runtimeContextSections.push(contextLayer); - const runtimePhase = isRecord(run.metadata?.phaseRuntime) ? run.metadata.phaseRuntime : null; - if (runtimePhase) { - const runtimeLines = [ - toOptionalString(runtimePhase.currentPhaseKey) ? `Current phase key: ${runtimePhase.currentPhaseKey}` : null, - toOptionalString(runtimePhase.currentPhaseName) ? `Current phase name: ${runtimePhase.currentPhaseName}` : null, - ].filter((entry): entry is string => Boolean(entry)); - if (runtimeLines.length > 0) { - runtimeContextSections.push(runtimeLines.join("\n")); - } - } - if (runtimeContextSections.length > 0) { - pushLayer(layers, { - label: "Runtime context references", - source: "runtime_context", - sourceKind: "live_effective_prompt", - editable: false, - text: runtimeContextSections.join("\n\n"), - description: "Persisted context snapshot references. Full pack bodies are not stored in the inspector data model.", - }); - } - - const fullPrompt = layers.map((layer) => `## ${layer.label}\n${layer.text}`).join("\n\n"); - return { - target: "worker", - runId: run.id, - missionId: run.missionId, - stepId: step.id, - phaseKey, - phaseName, - title: `${step.title} prompt`, - notes, - layers, - fullPrompt, - }; -} - -function buildCoordinatorRulesSection(rules: CoordinatorUserRules | undefined): string | null { - if (!rules) return null; - const ruleLines: string[] = []; - if (rules.providerPreference) ruleLines.push(`- Provider preference: ${rules.providerPreference}`); - if (rules.costMode) ruleLines.push(`- Cost mode: ${rules.costMode}`); - if (rules.maxParallelWorkers != null) ruleLines.push(`- Maximum parallel workers: ${rules.maxParallelWorkers}`); - if (rules.allowParallelAgents != null) ruleLines.push(`- Parallel agents: ${rules.allowParallelAgents ? "enabled" : "disabled (run work sequentially)"}`); - if (rules.allowSubAgents != null) ruleLines.push(`- Sub-agents: ${rules.allowSubAgents ? "enabled" : "disabled (do not use nested delegation)"}`); - if (rules.allowClaudeAgentTeams != null) ruleLines.push(`- Claude native agent teams: ${rules.allowClaudeAgentTeams ? "enabled" : "disabled"}`); - if (rules.laneStrategy) ruleLines.push(`- Lane strategy: ${rules.laneStrategy}`); - if (rules.customInstructions) ruleLines.push(`- Custom instructions: ${rules.customInstructions}`); - if (rules.coordinatorModel) ruleLines.push(`- Coordinator model: ${rules.coordinatorModel} (user selected)`); - if (rules.closeoutContract) ruleLines.push(`- Closeout contract: ${rules.closeoutContract}`); - if (rules.budgetLimitUsd != null) ruleLines.push(`- Budget limit: $${rules.budgetLimitUsd.toFixed(2)} USD`); - if (rules.budgetLimitTokens != null) ruleLines.push(`- Token budget limit: ${rules.budgetLimitTokens.toLocaleString()} tokens`); - if (rules.recoveryEnabled != null) ruleLines.push(`- Recovery loops: ${rules.recoveryEnabled ? `enabled (max ${rules.recoveryMaxIterations ?? 3} iterations)` : "disabled"}`); - return ruleLines.length > 0 ? ruleLines.join("\n") : null; -} - -function buildCoordinatorPhasesSection(phases: PhaseCard[] | undefined): string | null { - if (!Array.isArray(phases) || phases.length === 0) return null; - return phases - .slice() - .sort((a, b) => a.position - b.position) - .map((phase, index) => { - const lines: string[] = []; - const customTag = phase.isCustom ? "[CUSTOM] " : ""; - lines.push(`${index + 1}. ${customTag}${phase.name.toUpperCase()} (model: ${phase.model.modelId})`); - if (phase.description) lines.push(` Description: ${phase.description}`); - if (phase.instructions) lines.push(` Instructions: ${phase.instructions}`); - if (phase.validationGate.tier !== "none") { - lines.push(` Validation: ${phase.validationGate.tier.replace("-", " ")} ${phase.validationGate.required ? "(required)" : "(optional)"}`); - } - if (phase.askQuestions.enabled) { - const parsedMaxQuestions = Number(phase.askQuestions.maxQuestions); - const clampedMaxQuestions = Number.isFinite(parsedMaxQuestions) - ? Math.max(1, Math.min(10, Math.floor(parsedMaxQuestions))) - : 5; - const limit = phase.askQuestions.maxQuestions == null - ? "unlimited" - : `max ${clampedMaxQuestions} questions`; - const requirement = phase.askQuestions.requiredBeforeExit === true - ? "required before phase exit" - : "when needed"; - lines.push(` Ask Questions: enabled (${requirement}, ${limit})`); - } else { - lines.push(" Ask Questions: disabled"); - } - return lines.join("\n"); - }) - .join("\n"); -} - -function buildCoordinatorProjectContextSection(projectContext: CoordinatorProjectContext | undefined): string | null { - if (!projectContext) return null; - const lines: string[] = [`Project root: ${projectContext.projectRoot}`]; - if (Array.isArray(projectContext.projectDocPaths) && projectContext.projectDocPaths.length > 0) { - lines.push("Likely project docs:"); - for (const docPath of projectContext.projectDocPaths.slice(0, 40)) { - lines.push(`- ${docPath}`); - } - } - if (Array.isArray(projectContext.projectHints) && projectContext.projectHints.length > 0) { - lines.push("Project context hints:"); - for (const entry of projectContext.projectHints.slice(0, 12)) { - lines.push(`- ${entry}`); - } - } - if (projectContext.fileTree) { - lines.push(`File structure:\n${projectContext.fileTree}`); - } - return lines.join("\n"); -} - -function buildCoordinatorProvidersSection(providers: CoordinatorAvailableProvider[] | undefined): string { - const available = Array.isArray(providers) - ? providers.filter((provider) => provider.available).map((provider) => provider.name) - : []; - if (available.length === 0) return "Provider worker (spawn_worker) is available; provider availability was not persisted on this run."; - return [ - "Provider worker (spawn_worker) — choose model per worker with `modelId`; CLI models run as subprocess sessions and API/local models run in-process.", - `Currently available providers: ${available.join(", ")}`, - ].join("\n"); -} - -export function buildCoordinatorPromptInspector(args: { - runId: string; - missionId: string; - missionGoal: string; - phases?: PhaseCard[]; - userRules?: CoordinatorUserRules; - projectContext?: CoordinatorProjectContext; - availableProviders?: CoordinatorAvailableProvider[]; - currentPhaseKey?: string | null; - currentPhaseName?: string | null; -}): OrchestratorPromptInspector { - const layers: OrchestratorPromptLayer[] = []; - const notes = [ - "Coordinator prompt includes hidden planning and orchestration protocol beyond the editable phase card text.", - "Editing a phase instruction changes only the custom instruction layer; it does not replace coordinator system guidance, runtime constraints, or lane-management rules.", - ]; - - pushLayer(layers, { - label: "Coordinator identity and autonomy contract", - source: "system_owned", - sourceKind: "system_owned", - editable: false, - text: [ - "You are the team lead for a software engineering mission. You have a team of AI coding agents (workers) you can spawn, steer, and shut down.", - "You are the persistent brain. Workers are disposable hands.", - "You are NOT a task router or dispatcher. You are a thinking, reasoning team lead who reads code, understands architecture, makes judgment calls, and owns the outcome.", - `Mission goal: ${args.missionGoal}`, - `Run ID: ${args.runId}`, - `Mission ID: ${args.missionId}`, - ].join("\n\n"), - description: "System-owned coordinator prompt root.", - }); - - const rulesSection = buildCoordinatorRulesSection(args.userRules); - if (rulesSection) { - pushLayer(layers, { - label: "User-configured rules", - source: "runtime_overlay", - sourceKind: "run_snapshot", - editable: false, - text: rulesSection, - description: "Frozen user settings that constrain the coordinator.", - }); - } - - const phasesSection = buildCoordinatorPhasesSection(args.phases); - if (phasesSection) { - pushLayer(layers, { - label: "Phase configuration snapshot", - source: "phase_snapshot", - sourceKind: "run_snapshot", - editable: false, - text: phasesSection, - description: "Frozen run snapshot of the mission phase profile.", - }); - } - - pushLayer(layers, { - label: "Planning and validation protocol", - source: "system_owned", - sourceKind: "system_owned", - editable: false, - text: [ - "Phase ordering is enforced by runtime tools. Do not bypass phase gates.", - "Validation is a runtime contract, not advisory behavior.", - "Dedicated required validation is auto-spawned by runtime; do not simulate it.", - "If a Planning phase is enabled, start with one read-only planning worker, wait for its output, then explicitly advance phase before spawning code-changing workers.", - "Planning questions, when needed, should use ADE's question UI (`ask_user` for tracked mission tools, `request_user_input` / provider-native question cards for managed chat workers). Provider-native plan approval prompts are not part of mission flow.", - "Never spawn a code-changing worker while the run is still in Planning.", - ].join("\n"), - description: "System-owned coordinator protocol that sits outside editable phase text.", - }); - - pushLayer(layers, { - label: "Lane and delegation guardrails", - source: "system_owned", - sourceKind: "system_owned", - editable: false, - text: [ - "Use delegate_to_subagent only for parent-owned child work; use spawn_worker for independent top-level work.", - "Hard constraints like allowParallelAgents, allowSubAgents, and allowClaudeAgentTeams are tool-enforced.", - "Mission lane isolation is the default operating model; workers should stay on the mission lane or child lanes provisioned for the run.", - ].join("\n"), - description: "System-owned orchestration guardrails.", - }); - - const providersSection = buildCoordinatorProvidersSection(args.availableProviders); - pushLayer(layers, { - label: "Available worker/runtime surface", - source: "runtime_context", - sourceKind: "live_effective_prompt", - editable: false, - text: [ - providersSection, - "", - ADE_CLI_AGENT_GUIDANCE, - ].join("\n"), - description: "Runtime availability context for worker spawning.", - }); - - const projectSection = buildCoordinatorProjectContextSection(args.projectContext); - if (projectSection) { - pushLayer(layers, { - label: "Project context", - source: "runtime_context", - sourceKind: "live_effective_prompt", - editable: false, - text: projectSection, - description: "Project-root, docs, planner hints, and file-structure context given to the coordinator.", - }); - } - - const fullPrompt = layers.map((layer) => `## ${layer.label}\n${layer.text}`).join("\n\n"); - return { - target: "coordinator", - runId: args.runId, - missionId: args.missionId, - stepId: null, - phaseKey: args.currentPhaseKey ?? null, - phaseName: args.currentPhaseName ?? null, - title: "Coordinator prompt", - notes, - layers, - fullPrompt, - }; -} - -export function buildPlanningPromptPreview(args: GetPlanningPromptPreviewArgs): OrchestratorPromptInspector { - const sortedPhases = [...args.phases].sort((a, b) => a.position - b.position); - const planningPhase = { - ...(sortedPhases.find((phase) => phase.id === args.phase.id || phase.phaseKey === args.phase.phaseKey) ?? {}), - ...args.phase, - }; - const prompt = buildExactWorkerPrompt({ - runId: toOptionalString(args.runId) ?? "preview-run", - missionId: toOptionalString(args.missionId) ?? "preview-mission", - missionGoal: args.missionPrompt.trim(), - phase: planningPhase, - }); - - const layers: OrchestratorPromptLayer[] = []; - pushLayer(layers, { - label: "Exact composed planner prompt", - source: "system_owned", - sourceKind: "live_effective_prompt", - editable: false, - text: prompt, - description: "This is the actual planner-worker prompt shape ADE assembles from the planning phase contract and your current custom instructions.", - }); - - return { - target: "worker", - runId: toOptionalString(args.runId) ?? "preview-run", - missionId: toOptionalString(args.missionId) ?? "preview-mission", - stepId: null, - phaseKey: planningPhase.phaseKey, - phaseName: planningPhase.name, - title: "Planning worker prompt", - notes: [ - "Read-only, system-composed planner prompt preview.", - "Edit the Custom Instructions field below to change the phase-specific instruction layer that gets folded into this prompt.", - ], - layers, - fullPrompt: prompt, - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts deleted file mode 100644 index e1952b1ef..000000000 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts +++ /dev/null @@ -1,816 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { OrchestratorExecutorAdapter } from "./orchestratorService"; -import { buildFullPrompt, createBaseOrchestratorAdapter, shellEscapeArg, shellInlineDecodedArg, type AdapterLaunchCommand } from "./baseOrchestratorAdapter"; -import { - classifyWorkerExecutionPath, - getModelById, - resolveCliProviderForModel, - resolveModelAlias, - resolveModelDescriptor, - resolveProviderGroupForModel, - type ModelDescriptor, -} from "../../../shared/modelRegistry"; -import type { - AgentChatExecutionMode, - AgentChatPermissionMode, - TeamRuntimeConfig, -} from "../../../shared/types"; -import type { MissionPermissionConfig, MissionProviderPermissions } from "../../../shared/types/missions"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; -import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils"; -import { resolveCodexExecutable } from "../ai/codexExecutable"; -import type { createAgentChatService } from "../chat/agentChatService"; -import { - mapPermissionToClaude, - mapPermissionToCodex, - normalizeMissionPermissions, - providerPermissionsToLegacyConfig, -} from "./permissionMapping"; -import { resolveCliSpawnInvocation } from "../shared/processExecution"; - -const WORKER_ENV_KEYS = [ - "ADE_MISSION_ID", - "ADE_RUN_ID", - "ADE_STEP_ID", - "ADE_ATTEMPT_ID", - "ADE_RPC_URL", - "ADE_RPC_SOCKET_PATH", - "ADE_DEFAULT_ROLE", - "ADE_OWNER_ID", -] as const; - -type WorkerEnvKey = typeof WORKER_ENV_KEYS[number]; -type WorkerEnvVars = Partial<Record<WorkerEnvKey, string>> & Record<string, string>; - -/** - * Build worker identity env vars. ADE-aware CLIs and child processes use these - * to resolve caller context without POSIX-only inline assignment syntax. - */ -function buildWorkerEnvVars(args: { - missionId: string; - runId: string; - stepId: string; - attemptId: string; - rpcUrl?: string | null; - rpcSocketPath?: string | null; - ownerId?: string | null; -}): WorkerEnvVars { - return { - ADE_MISSION_ID: args.missionId, - ADE_RUN_ID: args.runId, - ADE_STEP_ID: args.stepId, - ADE_ATTEMPT_ID: args.attemptId, - ...(args.rpcUrl ? { ADE_RPC_URL: args.rpcUrl } : {}), - ...(args.rpcSocketPath ? { ADE_RPC_SOCKET_PATH: args.rpcSocketPath } : {}), - ADE_DEFAULT_ROLE: "agent", - ...(args.ownerId ? { ADE_OWNER_ID: args.ownerId } : {}), - }; -} - -function previewWorkerEnvVars(env: WorkerEnvVars): string[] { - const parts: string[] = []; - for (const key of WORKER_ENV_KEYS) { - const value = env[key]; - if (!value) continue; - parts.push(key === "ADE_DEFAULT_ROLE" ? `${key}=agent` : `${key}=${shellEscapeArg(value)}`); - } - return parts; -} - -function resolveWorkerOwnerId(metadata: Record<string, unknown> | null | undefined): string | null { - return typeof metadata?.employeeAgentId === "string" && metadata.employeeAgentId.trim().length > 0 - ? metadata.employeeAgentId.trim() - : null; -} - -function resolveStepReasoningEffort(metadata: Record<string, unknown> | null | undefined): string | undefined { - const explicit = typeof metadata?.reasoningEffort === "string" ? metadata.reasoningEffort.trim() : ""; - if (explicit.length > 0) return explicit; - - const phaseModel = metadata?.phaseModel; - if (phaseModel && typeof phaseModel === "object" && !Array.isArray(phaseModel)) { - const thinkingLevel = (phaseModel as Record<string, unknown>).thinkingLevel; - const fromPhase = typeof thinkingLevel === "string" ? thinkingLevel.trim() : ""; - if (fromPhase.length > 0) return fromPhase; - } - - return undefined; -} - -function resolveWorkerRpcSocketPath(projectRoot: string, step: { metadata?: Record<string, unknown> | null }): string | null { - const rootSocketPath = resolveAdeLayout(projectRoot).socketPath; - const laneWorktreePath = typeof step.metadata?.laneWorktreePath === "string" - ? step.metadata.laneWorktreePath.trim() - : ""; - if (!laneWorktreePath.length) return rootSocketPath; - - const laneSocketPath = path.join(laneWorktreePath, ".ade", "ade.sock"); - try { - fs.mkdirSync(path.dirname(laneSocketPath), { recursive: true }); - return laneSocketPath; - } catch { - return rootSocketPath; - } -} - -export function getProviderAdapterUnsupportedModelReason(modelRef: string): string | null { - const descriptor = resolveModelDescriptor(modelRef); - if (!descriptor) { - return `Model '${modelRef}' is not registered.`; - } - const cliProvider = resolveCliProviderForModel(descriptor); - if (cliProvider) return null; - const executionPath = classifyWorkerExecutionPath(descriptor); - return `Model '${descriptor.id}' requires ${executionPath} provider-owned execution (${descriptor.family}), but the shell-startup fallback only supports Claude/Codex CLI models. Use the managed OpenCode path for API and local models.`; -} - -function workerPromptFilePath(projectRoot: string, attemptId: string): string { - return path.join(resolveAdeLayout(projectRoot).workerPromptsDir, `worker-${attemptId}.txt`); -} - -function workerLaunchFilePath(projectRoot: string, attemptId: string): string { - return path.join(resolveAdeLayout(projectRoot).workerPromptsDir, `worker-${attemptId}.launch.json`); -} - -const WORKER_CLI_LAUNCHER_SCRIPT = ` -const fs = require("fs"); -const { spawn, spawnSync } = require("child_process"); -const specPath = process.argv[1]; -let done = false; -let child = null; -function terminateChild() { - if (!child || !child.pid) return; - try { - if (process.platform === "win32") { - spawnSync("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }); - } else { - child.kill("SIGTERM"); - } - } catch {} -} -function finish(code) { - if (done) return; - done = true; - process.exit(typeof code === "number" ? code : 1); -} -process.on("SIGTERM", () => { - terminateChild(); - finish(143); -}); -process.on("SIGINT", () => { - terminateChild(); - finish(130); -}); -process.on("exit", () => { - terminateChild(); -}); -const spec = JSON.parse(fs.readFileSync(specPath, "utf8")); -const childEnv = { ...process.env, ...(spec.env || {}) }; -delete childEnv.ELECTRON_RUN_AS_NODE; -child = spawn(spec.command, Array.isArray(spec.args) ? spec.args : [], { - cwd: spec.cwd || process.cwd(), - env: childEnv, - shell: false, - stdio: [spec.stdinFilePath ? "pipe" : "inherit", "inherit", "inherit"], - windowsHide: false, - windowsVerbatimArguments: !!spec.windowsVerbatimArguments -}); -child.on("error", (err) => { - console.error("[ADE] Failed to launch worker CLI: " + (err && err.message ? err.message : String(err))); - finish(127); -}); -child.on("exit", (code, signal) => { - if (signal) { - console.error("[ADE] Worker CLI exited from signal " + signal + "."); - finish(1); - return; - } - finish(code == null ? 0 : code); -}); -if (spec.stdinFilePath && child.stdin) { - child.stdin.on("error", () => {}); - const stream = fs.createReadStream(spec.stdinFilePath); - stream.on("error", (err) => { - console.error("[ADE] Failed to read worker prompt: " + (err && err.message ? err.message : String(err))); - try { child.kill(); } catch {} - finish(1); - }); - stream.pipe(child.stdin); -} -`; - -const CLAUDE_READ_ONLY_NATIVE_TOOLS = [ - "Read", - "Glob", - "Grep", -] as const; - -function dedupeAllowedTools(entries: readonly string[]): string[] { - const seen = new Set<string>(); - const out: string[] = []; - for (const entry of entries) { - const trimmed = entry.trim(); - if (!trimmed.length || seen.has(trimmed)) continue; - seen.add(trimmed); - out.push(trimmed); - } - return out; -} - -export function buildClaudeReadOnlyWorkerAllowedTools(extraToolNames: readonly string[] = []): string[] { - return dedupeAllowedTools([ - ...CLAUDE_READ_ONLY_NATIVE_TOOLS, - ...extraToolNames, - ]); -} - -export function writeWorkerPromptFile(args: { - projectRoot: string; - attemptId: string; - prompt: string; -}): string { - const promptPath = workerPromptFilePath(args.projectRoot, args.attemptId); - fs.mkdirSync(path.dirname(promptPath), { recursive: true }); - fs.writeFileSync(promptPath, args.prompt, "utf8"); - return promptPath; -} - -export function writeWorkerLaunchFile(args: { - projectRoot: string; - attemptId: string; - command: string; - commandArgs: string[]; - promptFilePath: string; - env?: Record<string, string>; -}): string { - const launchPath = workerLaunchFilePath(args.projectRoot, args.attemptId); - fs.mkdirSync(path.dirname(launchPath), { recursive: true }); - const invocation = resolveCliSpawnInvocation( - args.command, - args.commandArgs, - { ...process.env, ...(args.env ?? {}) }, - ); - fs.writeFileSync( - launchPath, - JSON.stringify({ - command: invocation.command, - args: invocation.args, - windowsVerbatimArguments: invocation.windowsVerbatimArguments ?? false, - stdinFilePath: args.promptFilePath, - env: args.env ?? {}, - }), - "utf8", - ); - return launchPath; -} - -export function nodeWorkerLaunch(args: { - startupCommand: string; - launchFilePath: string; - env?: Record<string, string>; -}): AdapterLaunchCommand { - return { - startupCommand: args.startupCommand, - command: process.execPath, - args: ["-e", WORKER_CLI_LAUNCHER_SCRIPT, args.launchFilePath], - env: { - ELECTRON_RUN_AS_NODE: "1", - ...(args.env ?? {}), - }, - }; -} - -export function resolveOpenCodeRuntimeRoot(): string { - const startPoints = [ - process.cwd(), - typeof __dirname === "string" ? __dirname : null, - path.resolve(process.cwd(), ".."), - path.resolve(process.cwd(), "..", ".."), - ]; - - for (const start of startPoints) { - if (!start) continue; - let dir = path.resolve(start); - for (let i = 0; i < 12; i += 1) { - if (fs.existsSync(path.join(dir, "apps", "ade-cli", "package.json"))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } - - return path.resolve(process.cwd()); -} - -export function cleanupWorkerRuntimeFiles(projectRoot: string, attemptId: string, _laneWorktreePath?: string | null): void { - try { - fs.unlinkSync(workerPromptFilePath(projectRoot, attemptId)); - } catch { - // Ignore — prompt file may already be removed or never created. - } - try { - fs.unlinkSync(workerLaunchFilePath(projectRoot, attemptId)); - } catch { - // Ignore — launch file may already be removed or never created. - } -} - -/** - * Remove stale worker prompt files from previous runs. - * Called at adapter creation time. - */ -function cleanupStaleFilesInDir(dir: string, prefix: string, suffix: string): void { - try { - for (const entry of fs.readdirSync(dir)) { - if (entry.startsWith(prefix) && entry.endsWith(suffix)) { - try { fs.unlinkSync(path.join(dir, entry)); } catch { /* ignore */ } - } - } - } catch { - // Directory may not exist yet - } -} - -function cleanupStaleWorkerRuntimeFiles(projectRoot: string): void { - const layout = resolveAdeLayout(projectRoot); - cleanupStaleFilesInDir( - layout.workerPromptsDir, - "worker-", ".txt", - ); - cleanupStaleFilesInDir( - layout.workerPromptsDir, - "worker-", ".launch.json", - ); -} - -const VALID_PERMISSION_MODES = new Set<string>(["default", "plan", "edit", "full-auto", "config-toml"]); - -function resolveManagedPermissionMode(args: { - provider: "claude" | "codex" | "opencode" | "cursor" | "droid"; - descriptor?: ModelDescriptor; - permissionConfig: LegacyPermissionConfig | undefined; - readOnlyExecution: boolean; -}): AgentChatPermissionMode | undefined { - if (args.readOnlyExecution) return "plan"; - const providers = args.permissionConfig?._providers; - const candidate = - args.provider === "cursor" || args.provider === "droid" - ? (( - args.provider === "droid" - ? (providers?.droid ?? providers?.cursor ?? providers?.opencode) - : (providers?.cursor ?? providers?.opencode) - ) as string | undefined) - : (providers?.[args.provider] as string | undefined); - const normalizedCandidate = typeof candidate === "string" && VALID_PERMISSION_MODES.has(candidate) - ? candidate as AgentChatPermissionMode - : undefined; - if (args.descriptor?.authTypes?.includes("local")) { - if (args.descriptor.harnessProfile === "read_only") return "plan"; - if (args.descriptor.harnessProfile === "guarded") return "plan"; - } - return normalizedCandidate; -} - -function mapPermissionModeToNativeFields( - provider: "claude" | "codex" | "opencode" | "cursor" | "droid", - mode: AgentChatPermissionMode | undefined, -): Partial<Pick<import("../../../shared/types").AgentChatCreateArgs, "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "opencodePermissionMode" | "droidPermissionMode" | "cursorModeId">> { - if (!mode) return {}; - // "config-toml" means the worker should inherit permissions from the - // provider/repo config (e.g. a .toml settings file). Don't rewrite it - // into explicit native permission fields — pass through with no overrides - // so the managed session respects the config it was supposed to inherit. - if (mode === "config-toml") return {}; - if (provider === "claude") { - const map: Record<string, import("../../../shared/types").AgentChatClaudePermissionMode> = { - "full-auto": "bypassPermissions", - "edit": "acceptEdits", - "plan": "plan", - "default": "default", - }; - return { claudePermissionMode: map[mode] ?? "default" }; - } - if (provider === "codex") { - if (mode === "full-auto") return { codexApprovalPolicy: "never", codexSandbox: "danger-full-access" }; - if (mode === "edit") return { codexApprovalPolicy: "untrusted", codexSandbox: "workspace-write" }; - if (mode === "default") return { codexApprovalPolicy: "on-request", codexSandbox: "workspace-write" }; - return { codexApprovalPolicy: "on-request", codexSandbox: "read-only" }; - } - if (provider === "droid") { - if (mode === "full-auto") return { droidPermissionMode: "auto-high" }; - if (mode === "default") return { droidPermissionMode: "auto-medium" }; - if (mode === "edit") return { droidPermissionMode: "auto-low" }; - return { droidPermissionMode: "read-only" }; - } - if (provider === "cursor") { - if (mode === "full-auto") return { cursorModeId: "full-auto" }; - if (mode === "plan") return { cursorModeId: "plan" }; - return { cursorModeId: "agent" }; - } - const umap: Record<string, import("../../../shared/types").AgentChatOpenCodePermissionMode> = { - "full-auto": "full-auto", - "edit": "edit", - "plan": "plan", - }; - return { opencodePermissionMode: umap[mode] ?? "edit" }; -} - -function resolveManagedExecutionMode(args: { - provider: "claude" | "codex" | "opencode" | "cursor" | "droid"; - teamRuntime?: TeamRuntimeConfig; -}): AgentChatExecutionMode { - if (args.provider === "cursor" || args.provider === "droid") { - return "focused"; - } - if (args.provider === "claude") { - if ( - args.teamRuntime?.enabled - && args.teamRuntime.allowClaudeAgentTeams !== false - && (args.teamRuntime.targetProvider === "claude" || args.teamRuntime.targetProvider === "auto") - ) { - return "teams"; - } - return args.teamRuntime?.allowSubAgents === false ? "focused" : "subagents"; - } - if (args.provider === "codex") { - return args.teamRuntime?.allowParallelAgents === false ? "focused" : "parallel"; - } - return "focused"; -} - -type LegacyPermissionConfig = { - cli?: { - mode?: "read-only" | "edit" | "full-auto"; - sandboxPermissions?: "read-only" | "workspace-write" | "danger-full-access"; - writablePaths?: string[]; - allowedTools?: string[]; - }; - inProcess?: { - mode?: "plan" | "edit" | "full-auto"; - }; - _providers?: MissionProviderPermissions; -}; - -export function forceReadOnlyPermissionConfig( - permissionConfig: LegacyPermissionConfig | undefined, - readOnlyExecution: boolean, -): LegacyPermissionConfig | undefined { - if (!readOnlyExecution) return permissionConfig; - const providers = normalizeMissionPermissions(permissionConfig as MissionPermissionConfig | undefined); - return providerPermissionsToLegacyConfig({ - ...providers, - claude: "default", - codex: "plan", - opencode: "plan", - codexSandbox: "read-only", - writablePaths: [], - }); -} - -/** - * Provider orchestrator adapter that handles all ADE-owned worker providers. - * For CLI-wrapped models (Claude CLI, Codex CLI), it delegates to the appropriate CLI. - * For API-key models, it constructs a direct SDK invocation command. - */ -export function createProviderOrchestratorAdapter(options?: { - projectRoot?: string; - workspaceRoot?: string; - runtimeRoot?: string; - agentChatService?: ReturnType<typeof createAgentChatService> | null; -}): OrchestratorExecutorAdapter { - const runtimeRoot = typeof options?.runtimeRoot === "string" && options.runtimeRoot.trim().length - ? options.runtimeRoot.trim() - : resolveOpenCodeRuntimeRoot(); - const projectRoot = typeof options?.projectRoot === "string" && options.projectRoot.trim().length - ? options.projectRoot.trim() - : undefined; - const workspaceRoot = typeof options?.workspaceRoot === "string" && options.workspaceRoot.trim().length - ? options.workspaceRoot.trim() - : (projectRoot ?? runtimeRoot); - const canonicalProjectRoot = projectRoot ?? workspaceRoot; - cleanupStaleWorkerRuntimeFiles(canonicalProjectRoot); - - const shellAdapter = createBaseOrchestratorAdapter({ - executorKind: "opencode", - sessionType: "opencode-orchestrated", - - buildOverrideCommand: ({ prompt }) => { - // For override commands, try to detect the best CLI - // Default to claude since it's the most common - const resolvedClaude = resolveClaudeCodeExecutable(); - return { - startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`, - command: resolvedClaude.path, - args: ["-p", prompt], - }; - }, - - buildStartupCommand: ({ prompt, model, step, run, attempt, permissionConfig, teamRuntime }) => { - const descriptor = getModelById(model) ?? resolveModelAlias(model); - const requiresPlanApproval = - step.metadata?.requiresPlanApproval === true || step.metadata?.coordinationPattern === "plan_then_implement"; - const readOnlyExecution = step.metadata?.readOnlyExecution === true || requiresPlanApproval; - const effectivePermissionConfig = forceReadOnlyPermissionConfig(permissionConfig, readOnlyExecution); - const workerEnv = buildWorkerEnvVars({ - missionId: run.missionId, - runId: run.id, - stepId: step.id, - attemptId: attempt.id, - rpcUrl: process.env.ADE_RPC_URL, - rpcSocketPath: resolveWorkerRpcSocketPath(canonicalProjectRoot, step), - ownerId: resolveWorkerOwnerId(run.metadata), - }); - // Determine which CLI to use based on the model - if (descriptor?.isCliWrapped && descriptor.family === "anthropic") { - // Claude CLI path — use per-provider permission when available - const cliModel = resolveClaudeCliModel(descriptor?.providerModelId ?? model); - const claudeProviderMode = effectivePermissionConfig?._providers?.claude; - const mappedClaude = mapPermissionToClaude(claudeProviderMode); - const dangerouslySkip = !readOnlyExecution && mappedClaude === "bypassPermissions"; - const permissionMode = mappedClaude === "bypassPermissions" - ? "acceptEdits" - : mappedClaude; - const configuredAllowedTools = - effectivePermissionConfig?._providers?.allowedTools ?? effectivePermissionConfig?.cli?.allowedTools ?? []; - const allowedTools = readOnlyExecution - ? buildClaudeReadOnlyWorkerAllowedTools() - : dedupeAllowedTools(configuredAllowedTools); - - const resolvedClaude = resolveClaudeCodeExecutable(); - const commandArgs: string[] = ["--model", cliModel]; - const previewParts: string[] = ["claude", "--model", shellEscapeArg(cliModel)]; - - if (dangerouslySkip) { - commandArgs.push("--dangerously-skip-permissions"); - previewParts.push("--dangerously-skip-permissions"); - } else { - commandArgs.push("--permission-mode", permissionMode); - previewParts.push("--permission-mode", shellEscapeArg(permissionMode)); - } - - if (allowedTools.length > 0) { - commandArgs.push("--allowedTools", allowedTools.join(",")); - previewParts.push("--allowedTools", shellEscapeArg(allowedTools.join(","))); - } - - const promptFilePath = writeWorkerPromptFile({ - projectRoot: canonicalProjectRoot, - attemptId: attempt.id, - prompt, - }); - commandArgs.push("-p"); - previewParts.push("-p"); - - const launchEnv: Record<string, string> = { ...workerEnv }; - const envParts = previewWorkerEnvVars(workerEnv); - if ( - teamRuntime?.enabled - && teamRuntime.allowClaudeAgentTeams !== false - && (teamRuntime.targetProvider === "claude" || teamRuntime.targetProvider === "auto") - ) { - launchEnv.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; - envParts.push("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1"); - } - - const cmd = previewParts.join(" "); - const startup = `exec ${cmd} < ${shellEscapeArg(promptFilePath)}`; - const startupCommand = envParts.length > 0 ? `${envParts.join(" ")} ${startup}` : startup; - const launchFilePath = writeWorkerLaunchFile({ - projectRoot: canonicalProjectRoot, - attemptId: attempt.id, - command: resolvedClaude.path, - commandArgs, - promptFilePath, - env: launchEnv, - }); - return nodeWorkerLaunch({ - startupCommand, - launchFilePath, - env: launchEnv, - }); - } - - if (descriptor?.isCliWrapped && descriptor.family === "openai") { - // Codex CLI path — use per-provider permission when available - const reasoningEffort = resolveStepReasoningEffort(step.metadata); - const codexProviderMode = effectivePermissionConfig?._providers?.codex; - const mappedCodex = mapPermissionToCodex(codexProviderMode); - const useCodexConfig = codexProviderMode === "config-toml" || mappedCodex == null; - const approvalPolicy = mappedCodex?.approvalPolicy ?? "on-request"; - const sandboxMode = readOnlyExecution - ? "read-only" - : codexProviderMode === "full-auto" - ? mappedCodex?.sandbox ?? "danger-full-access" - : mappedCodex?.sandbox ?? effectivePermissionConfig?._providers?.codexSandbox ?? effectivePermissionConfig?.cli?.sandboxPermissions ?? "workspace-write"; - const writablePaths = effectivePermissionConfig?._providers?.writablePaths ?? effectivePermissionConfig?.cli?.writablePaths ?? []; - - const resolvedCodex = resolveCodexExecutable(); - const commandArgs: string[] = ["--model", resolveCodexCliModel(descriptor.providerModelId)]; - 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)); - } - - commandArgs.push("exec"); - previewParts.push("exec"); - - for (const wp of writablePaths) { - if (!wp.trim().length) continue; - commandArgs.push("--add-dir", wp.trim()); - previewParts.push("--add-dir", shellEscapeArg(wp.trim())); - } - - commandArgs.push("-"); - previewParts.push("-"); - - const launchEnv: Record<string, string> = { ...workerEnv }; - const envParts = previewWorkerEnvVars(workerEnv); - const cmd = previewParts.join(" "); - const promptFilePath = writeWorkerPromptFile({ - projectRoot: canonicalProjectRoot, - attemptId: attempt.id, - prompt, - }); - const startupCommand = `${envParts.length > 0 ? `${envParts.join(" ")} ` : ""}exec ${cmd} < ${shellEscapeArg(promptFilePath)}`; - const launchFilePath = writeWorkerLaunchFile({ - projectRoot: canonicalProjectRoot, - attemptId: attempt.id, - command: resolvedCodex.path, - commandArgs, - promptFilePath, - env: launchEnv, - }); - return nodeWorkerLaunch({ - startupCommand, - launchFilePath, - env: launchEnv, - }); - } - - // Non-CLI or unknown models can still run via the managed chat path. - // This shell fallback only exists for CLI-wrapped workers. - const unsupportedReason = getProviderAdapterUnsupportedModelReason(model) ?? `Model '${model}' is not supported by the provider adapter.`; - const failureMessage = `[ADE] Shell-startup fallback for the provider adapter only supports CLI-wrapped Anthropic/OpenAI models. ${unsupportedReason}`; - return { - startupCommand: `printf '%s\\n' ${shellEscapeArg(failureMessage)} >&2; exit 64`, - command: process.execPath, - args: ["-e", "console.error(process.argv[1]); process.exit(64);", failureMessage], - env: { ELECTRON_RUN_AS_NODE: "1" }, - }; - }, - - buildAcceptedMetadata: ({ model, filePatterns, steeringDirectiveCount, promptLength, reasoningEffort, startupCommandPreview }) => { - const descriptor = getModelById(model); - return { - adapterKind: "opencode", - model, - modelFamily: descriptor?.family ?? "unknown", - isCliWrapped: descriptor?.isCliWrapped ?? true, - reasoningEffort, - filePatterns: filePatterns.length ? filePatterns : undefined, - steeringDirectiveCount, - promptLength, - startupCommandPreview - }; - } - }); - - const agentChatService = options?.agentChatService ?? null; - - if (!agentChatService) { - return { - ...shellAdapter, - requiresLaneId: true, - }; - } - - return { - kind: "opencode", - requiresLaneId: true, - async start(args) { - const rawStartup = typeof args.step.metadata?.startupCommand === "string" ? args.step.metadata.startupCommand.trim() : ""; - if (rawStartup.length > 0) { - return shellAdapter.start(args); - } - - if (!args.step.laneId) { - return { - status: "failed", - errorClass: "policy", - errorMessage: "Provider executor requires step.laneId to create worker sessions." - }; - } - - const modelRef = typeof args.step.metadata?.modelId === "string" ? args.step.metadata.modelId.trim() : ""; - if (!modelRef.length) { - return { - status: "failed", - errorClass: "policy", - errorMessage: `Step '${args.step.stepKey}' is missing required metadata.modelId for provider execution.` - }; - } - - const descriptor = getModelById(modelRef) ?? resolveModelAlias(modelRef); - if (!descriptor) { - return { - status: "failed", - errorClass: "policy", - errorMessage: `Model '${modelRef}' is not registered.` - }; - } - - const teamRuntime = args.run.metadata && typeof args.run.metadata === "object" && !Array.isArray(args.run.metadata) - ? (args.run.metadata as Record<string, unknown>).teamRuntime as TeamRuntimeConfig | undefined - : undefined; - const requiresPlanApproval = - args.step.metadata?.requiresPlanApproval === true || args.step.metadata?.coordinationPattern === "plan_then_implement"; - const readOnlyExecution = args.step.metadata?.readOnlyExecution === true || requiresPlanApproval; - const effectivePermissionConfig = forceReadOnlyPermissionConfig(args.permissionConfig, readOnlyExecution); - const { prompt, filePatterns, steeringDirectiveCount } = buildFullPrompt(args, "opencode", { - workerRuntime: "managed_chat", - }); - const provider = resolveProviderGroupForModel(descriptor); - const model = descriptor.isCliWrapped ? descriptor.providerModelId : descriptor.id; - const reasoningEffort = resolveStepReasoningEffort(args.step.metadata); - const permissionMode = resolveManagedPermissionMode({ - provider, - descriptor, - permissionConfig: effectivePermissionConfig, - readOnlyExecution, - }); - const executionMode = resolveManagedExecutionMode({ - provider, - teamRuntime, - }); - const workerOwnerId = resolveWorkerOwnerId(args.run.metadata); - - try { - const configTomlFields = provider === "codex" && permissionMode === "config-toml" - ? { permissionMode, codexConfigSource: "config-toml" as const } - : {}; - const session = await agentChatService.createSession({ - laneId: args.step.laneId, - provider, - model, - modelId: descriptor.id, - title: args.step.title, - surface: "mission", - reasoningEffort: reasoningEffort ?? null, - ...mapPermissionModeToNativeFields(provider, permissionMode), - ...configTomlFields, - ...(workerOwnerId ? { identityKey: `agent:${workerOwnerId}` as const } : {}), - }); - return { - status: "accepted", - sessionId: session.id, - launch: { - prompt, - displayText: `Execute worker step "${args.step.title}".`, - reasoningEffort: reasoningEffort ?? null, - executionMode, - permissionMode: permissionMode ?? null, - }, - metadata: { - adapterKind: "opencode", - workerSessionKind: "managed_chat", - workerStreamSource: "agent_chat", - model: descriptor.id, - modelFamily: descriptor.family ?? "unknown", - isCliWrapped: descriptor.isCliWrapped ?? false, - reasoningEffort, - executionMode, - permissionMode, - filePatterns: filePatterns.length > 0 ? filePatterns : undefined, - steeringDirectiveCount, - promptLength: prompt.length, - startupCommandPreview: "[managed chat session]", - } - }; - } catch (error) { - return { - status: "failed", - errorClass: "startup_failure", - errorMessage: error instanceof Error ? error.message : String(error), - metadata: { - adapterKind: "opencode", - workerSessionKind: "managed_chat", - workerStreamSource: "agent_chat", - adapterState: "managed_chat_session_create_failed", - } - }; - } - }, - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/recoveryService.ts b/apps/desktop/src/main/services/orchestrator/recoveryService.ts deleted file mode 100644 index 255b5bc8a..000000000 --- a/apps/desktop/src/main/services/orchestrator/recoveryService.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * recoveryService.ts - * - * Failure recovery: handleFailedAttemptRecovery, applyAiRetryDecisionForFailedAttempt, - * runHealthSweep, recovery diagnosis logic, health sweep implementation. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import type { - OrchestratorContext, - AttemptRuntimeTracker, -} from "./orchestratorContext"; -import { - nowIso, - digestSignalText, - parseTerminalRuntimeState, - ATTEMPT_RUNTIME_PERSIST_INTERVAL_MS, -} from "./orchestratorContext"; -import { getErrorMessage } from "../shared/utils"; -import type { - OrchestratorExecutorKind, - TerminalRuntimeState, -} from "../../../shared/types"; - -// ── Attempt Runtime Tracker Helpers ────────────────────────────── - -export function ensureAttemptRuntimeTracker( - ctx: OrchestratorContext, - attemptId: string -): AttemptRuntimeTracker { - const existing = ctx.attemptRuntimeTrackers.get(attemptId); - if (existing) return existing; - const next: AttemptRuntimeTracker = { - lastPreviewDigest: null, - digestSinceMs: Date.now(), - repeatCount: 0, - lastWaitingInterventionAtMs: 0, - lastEventHeartbeatAtMs: 0, - lastWaitingNotifiedAtMs: 0, - lastQuestionThreadId: null, - lastQuestionMessageId: null, - lastPersistedAtMs: 0 - }; - ctx.attemptRuntimeTrackers.set(attemptId, next); - return next; -} - -/** - * Update attempt stagnation tracker. - */ -export function updateAttemptStagnationTracker( - ctx: OrchestratorContext, - attemptId: string, - preview: string | null -): { digest: string | null; stagnantMs: number } { - const tracker = ensureAttemptRuntimeTracker(ctx, attemptId); - const now = Date.now(); - const digest = digestSignalText(preview); - if (!digest) { - // No output — track how long we've had no meaningful output. - // A hung agent producing nothing should be flagged as potentially stagnant. - if (tracker.lastPreviewDigest !== null) { - // Transition from having output to no output — start tracking silence. - tracker.lastPreviewDigest = null; - tracker.repeatCount = 0; - tracker.digestSinceMs = now; - return { digest: null, stagnantMs: 0 }; - } - // Already had no output — report elapsed silence time. - return { digest: null, stagnantMs: Math.max(0, now - tracker.digestSinceMs) }; - } - if (tracker.lastPreviewDigest !== digest) { - tracker.lastPreviewDigest = digest; - tracker.repeatCount = 1; - tracker.digestSinceMs = now; - return { digest, stagnantMs: 0 }; - } - tracker.repeatCount += 1; - return { digest, stagnantMs: Math.max(0, now - tracker.digestSinceMs) }; -} - -/** - * Get recent session activity timestamp. - */ -export function getRecentSessionActivityAt( - ctx: OrchestratorContext, - sessionState: { sessionId?: string | null; at?: string } -): number { - const sessionId = sessionState.sessionId; - if (!sessionId) return 0; - const signal = ctx.sessionRuntimeSignals.get(sessionId); - return signal ? Date.parse(signal.at) : 0; -} - -/** - * Get recent attempt event activity timestamp. - */ -export function getRecentAttemptEventActivityAt( - ctx: OrchestratorContext, - attemptId: string -): number { - const tracker = ctx.attemptRuntimeTrackers.get(attemptId); - return tracker?.lastEventHeartbeatAtMs ?? 0; -} - -// ── Session DB Query Functions ────────────────────────────────── - -export type TrackedSessionState = { - laneId: string | null; - status: string; - endedAt: string | null; - exitCode: number | null; - lastOutputAt: string | null; - lastOutputPreview: string | null; - transcriptPath: string | null; -}; - -export function getTrackedSessionState( - ctx: OrchestratorContext, - sessionId: string -): TrackedSessionState | null { - const row = ctx.db.get<{ - lane_id: string | null; - status: string | null; - ended_at: string | null; - exit_code: number | null; - last_output_at: string | null; - last_output_preview: string | null; - transcript_path: string | null; - }>( - ` - select lane_id, status, ended_at, exit_code, last_output_at, last_output_preview, transcript_path - from terminal_sessions - where id = ? - limit 1 - `, - [sessionId] - ); - if (!row) return null; - return { - laneId: row.lane_id ?? null, - status: String(row.status ?? "").trim().toLowerCase(), - endedAt: typeof row.ended_at === "string" ? row.ended_at : null, - exitCode: Number.isFinite(Number(row.exit_code)) ? Number(row.exit_code) : null, - lastOutputAt: typeof row.last_output_at === "string" ? row.last_output_at : null, - lastOutputPreview: typeof row.last_output_preview === "string" ? row.last_output_preview : null, - transcriptPath: typeof row.transcript_path === "string" ? row.transcript_path : null - }; -} - -export type RunningAttemptForSession = { - attemptId: string; - runId: string; - stepId: string; - executorKind: OrchestratorExecutorKind; - attemptMetadataJson: string | null; - runMetadataJson: string | null; -}; - -// ── Hydration ─────────────────────────────────────────────────── - -/** - * Hydrate persisted attempt runtime trackers and session signals from the DB on startup. - */ -export function hydratePersistedAttemptRuntimeState( - ctx: OrchestratorContext -): void { - const nowMs = Date.now(); - try { - ctx.db.run( - ` - delete from orchestrator_attempt_runtime - where attempt_id in ( - select r.attempt_id - from orchestrator_attempt_runtime r - left join orchestrator_attempts a on a.id = r.attempt_id - where a.id is null or a.status != 'running' - ) - ` - ); - } catch (error) { - ctx.logger.warn("ai_orchestrator.runtime_state_prune_failed", { - error: getErrorMessage(error) - }); - } - - const rows = ctx.db.all<{ - attempt_id: string; - session_id: string | null; - runtime_state: string | null; - last_signal_at: string | null; - last_output_preview: string | null; - last_preview_digest: string | null; - digest_since_ms: number | null; - repeat_count: number | null; - last_waiting_intervention_at_ms: number | null; - last_event_heartbeat_at_ms: number | null; - last_waiting_notified_at_ms: number | null; - }>( - ` - select - r.attempt_id as attempt_id, - r.session_id as session_id, - r.runtime_state as runtime_state, - r.last_signal_at as last_signal_at, - r.last_output_preview as last_output_preview, - r.last_preview_digest as last_preview_digest, - r.digest_since_ms as digest_since_ms, - r.repeat_count as repeat_count, - r.last_waiting_intervention_at_ms as last_waiting_intervention_at_ms, - r.last_event_heartbeat_at_ms as last_event_heartbeat_at_ms, - r.last_waiting_notified_at_ms as last_waiting_notified_at_ms - from orchestrator_attempt_runtime r - join orchestrator_attempts a on a.id = r.attempt_id - where a.status = 'running' - ` - ); - if (!rows.length) return; - - for (const row of rows) { - const attemptId = String(row.attempt_id ?? "").trim(); - if (!attemptId.length) continue; - const tracker: AttemptRuntimeTracker = { - lastPreviewDigest: typeof row.last_preview_digest === "string" ? row.last_preview_digest : null, - digestSinceMs: Number.isFinite(Number(row.digest_since_ms)) - ? Math.max(0, Math.floor(Number(row.digest_since_ms))) - : nowMs, - repeatCount: Number.isFinite(Number(row.repeat_count)) - ? Math.max(0, Math.floor(Number(row.repeat_count))) - : 0, - lastWaitingInterventionAtMs: Number.isFinite(Number(row.last_waiting_intervention_at_ms)) - ? Math.max(0, Math.floor(Number(row.last_waiting_intervention_at_ms))) - : 0, - lastEventHeartbeatAtMs: Number.isFinite(Number(row.last_event_heartbeat_at_ms)) - ? Math.max(0, Math.floor(Number(row.last_event_heartbeat_at_ms))) - : 0, - lastWaitingNotifiedAtMs: Number.isFinite(Number(row.last_waiting_notified_at_ms)) - ? Math.max(0, Math.floor(Number(row.last_waiting_notified_at_ms))) - : 0, - lastQuestionThreadId: null, - lastQuestionMessageId: null, - lastPersistedAtMs: nowMs - }; - ctx.attemptRuntimeTrackers.set(attemptId, tracker); - - const sessionId = typeof row.session_id === "string" ? row.session_id.trim() : ""; - const runtimeState = parseTerminalRuntimeState(row.runtime_state); - const at = typeof row.last_signal_at === "string" && row.last_signal_at.trim().length ? row.last_signal_at : nowIso(); - if (!sessionId.length || !runtimeState) continue; - const existing = ctx.sessionRuntimeSignals.get(sessionId); - if (existing) { - const existingMs = Date.parse(existing.at); - const nextMs = Date.parse(at); - if (Number.isFinite(existingMs) && Number.isFinite(nextMs) && existingMs > nextMs) { - continue; - } - } - ctx.sessionRuntimeSignals.set(sessionId, { - laneId: null, - sessionId, - runtimeState, - lastOutputPreview: - typeof row.last_output_preview === "string" && row.last_output_preview.trim().length - ? row.last_output_preview.trim() - : null, - at - }); - } -} - -export function listRunningAttemptsForSession( - ctx: OrchestratorContext, - sessionId: string -): RunningAttemptForSession[] { - const rows = ctx.db.all<{ - attempt_id: string; - run_id: string; - step_id: string; - executor_kind: string | null; - attempt_metadata_json: string | null; - run_metadata_json: string | null; - }>( - ` - select - a.id as attempt_id, - a.run_id as run_id, - a.step_id as step_id, - a.executor_kind as executor_kind, - a.metadata_json as attempt_metadata_json, - r.metadata_json as run_metadata_json - from orchestrator_attempts a - join orchestrator_runs r on r.id = a.run_id - where a.status = 'running' - and a.executor_session_id = ? - order by a.created_at asc - `, - [sessionId] - ); - return rows - .map((row) => { - const executorKindRaw = String(row.executor_kind ?? "").trim(); - const executorKind: OrchestratorExecutorKind = - executorKindRaw === "claude" - || executorKindRaw === "codex" - || executorKindRaw === "cursor" - || executorKindRaw === "droid" - || executorKindRaw === "opencode" - || executorKindRaw === "shell" - || executorKindRaw === "manual" - ? executorKindRaw - : "manual"; - return { - attemptId: row.attempt_id, - runId: row.run_id, - stepId: row.step_id, - executorKind, - attemptMetadataJson: row.attempt_metadata_json ?? null, - runMetadataJson: row.run_metadata_json ?? null - }; - }) - .filter((row) => row.attemptId.length > 0 && row.runId.length > 0 && row.stepId.length > 0); -} - -// ── Runtime State Persistence ─────────────────────────────────── - -export function deletePersistedAttemptRuntimeState( - ctx: OrchestratorContext, - attemptId: string -): void { - try { - ctx.db.run(`delete from orchestrator_attempt_runtime where attempt_id = ?`, [attemptId]); - } catch (error) { - ctx.logger.warn("ai_orchestrator.runtime_state_delete_failed", { - attemptId, - error: getErrorMessage(error) - }); - } -} - -export function persistAttemptRuntimeState( - ctx: OrchestratorContext, - args: { - attemptId: string; - sessionId: string | null; - runtimeState: TerminalRuntimeState | null; - lastSignalAt: string | null; - lastOutputPreview: string | null; - force?: boolean; - } -): void { - const attemptId = String(args.attemptId ?? "").trim(); - if (!attemptId.length) return; - const tracker = ensureAttemptRuntimeTracker(ctx, attemptId); - const nowMs = Date.now(); - if (!args.force && nowMs - tracker.lastPersistedAtMs < ATTEMPT_RUNTIME_PERSIST_INTERVAL_MS) return; - const updatedAt = nowIso(); - try { - ctx.db.run( - ` - insert into orchestrator_attempt_runtime( - attempt_id, - session_id, - runtime_state, - last_signal_at, - last_output_preview, - last_preview_digest, - digest_since_ms, - repeat_count, - last_waiting_intervention_at_ms, - last_event_heartbeat_at_ms, - last_waiting_notified_at_ms, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - on conflict(attempt_id) do update set - session_id = excluded.session_id, - runtime_state = excluded.runtime_state, - last_signal_at = excluded.last_signal_at, - last_output_preview = excluded.last_output_preview, - last_preview_digest = excluded.last_preview_digest, - digest_since_ms = excluded.digest_since_ms, - repeat_count = excluded.repeat_count, - last_waiting_intervention_at_ms = excluded.last_waiting_intervention_at_ms, - last_event_heartbeat_at_ms = excluded.last_event_heartbeat_at_ms, - last_waiting_notified_at_ms = excluded.last_waiting_notified_at_ms, - updated_at = excluded.updated_at - `, - [ - attemptId, - args.sessionId, - args.runtimeState, - args.lastSignalAt, - args.lastOutputPreview, - tracker.lastPreviewDigest, - Math.max(0, Math.floor(tracker.digestSinceMs)), - Math.max(0, Math.floor(tracker.repeatCount)), - Math.max(0, Math.floor(tracker.lastWaitingInterventionAtMs)), - Math.max(0, Math.floor(tracker.lastEventHeartbeatAtMs)), - Math.max(0, Math.floor(tracker.lastWaitingNotifiedAtMs)), - updatedAt - ] - ); - tracker.lastPersistedAtMs = nowMs; - } catch (error) { - ctx.logger.warn("ai_orchestrator.runtime_state_persist_failed", { - attemptId, - error: getErrorMessage(error) - }); - } -} diff --git a/apps/desktop/src/main/services/orchestrator/runtimeEventRouter.test.ts b/apps/desktop/src/main/services/orchestrator/runtimeEventRouter.test.ts deleted file mode 100644 index 976e2518d..000000000 --- a/apps/desktop/src/main/services/orchestrator/runtimeEventRouter.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - buildRunStateSnapshot, - onAgentChatEvent, - onSessionRuntimeSignal, - routeEventToCoordinator, -} from "./runtimeEventRouter"; - -describe("runtimeEventRouter", () => { - it("buildRunStateSnapshot falls back to default parallelism cap when metadata is NaN", () => { - const ctx = { - orchestratorService: { - getRunGraph: vi.fn(() => ({ - run: { - metadata: { - autopilot: { - parallelismCap: "not-a-number", - }, - }, - }, - steps: [], - attempts: [], - claims: [], - })), - }, - } as any; - - const snapshot = buildRunStateSnapshot(ctx, "run-1"); - - expect(snapshot.parallelismCap).toBe(4); - expect(snapshot.activeAgentCount).toBe(0); - }); - - it("deduplicates repetitive routed events within a short window", () => { - vi.useFakeTimers(); - try { - const coordinator = { - injectEvent: vi.fn(), - } as any; - const event = { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.000Z", - reason: "validation_self_check_reminder", - } as any; - - routeEventToCoordinator(coordinator, event); - routeEventToCoordinator(coordinator, event); - expect(coordinator.injectEvent).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(800); - routeEventToCoordinator(coordinator, event); - expect(coordinator.injectEvent).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - - it("does not deduplicate critical runtime reasons inside the dedupe window", () => { - vi.useFakeTimers(); - try { - const coordinator = { - injectEvent: vi.fn(), - } as any; - const criticalEvent = { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.000Z", - reason: "completed", - } as any; - - routeEventToCoordinator(coordinator, criticalEvent); - routeEventToCoordinator(coordinator, criticalEvent); - - expect(coordinator.injectEvent).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - - it("ignores idle runtime churn so the coordinator stays quiet while workers are just running", () => { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - routeEventToCoordinator(coordinator, { - type: "orchestrator-attempt-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.000Z", - reason: "started", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-attempt-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.100Z", - reason: "session_attached", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-claim-updated", - runId: "run-1", - claimId: "claim-1", - at: "2026-03-03T00:00:00.200Z", - reason: "heartbeat", - } as any); - - expect(coordinator.injectEvent).not.toHaveBeenCalled(); - }); - - it("ignores coordinator-owned DAG churn and only wakes for actionable runtime events", () => { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - at: "2026-03-03T00:00:00.000Z", - reason: "steps_added", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - at: "2026-03-03T00:00:00.050Z", - reason: "dependencies_updated", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-run-updated", - runId: "run-1", - at: "2026-03-03T00:00:00.100Z", - reason: "status_updated", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.200Z", - reason: "attempt_completed", - } as any); - - expect(coordinator.injectEvent).toHaveBeenCalledTimes(1); - const message = coordinator.injectEvent.mock.calls[0]?.[1] as string; - expect(message).toContain("attempt_completed"); - }); - - it("routes worker report and phase transition reasons onto the coordinator live path", () => { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - at: "2026-03-03T00:00:00.000Z", - reason: "worker_status_report", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - at: "2026-03-03T00:00:00.050Z", - reason: "worker_result_report", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - at: "2026-03-03T00:00:00.100Z", - reason: "validation_report", - } as any); - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - at: "2026-03-03T00:00:00.150Z", - reason: "phase_transition", - } as any); - - expect(coordinator.injectEvent).toHaveBeenCalledTimes(4); - expect(String(coordinator.injectEvent.mock.calls[0]?.[1] ?? "")).toContain("worker_status_report"); - expect(String(coordinator.injectEvent.mock.calls[1]?.[1] ?? "")).toContain("worker_result_report"); - expect(String(coordinator.injectEvent.mock.calls[2]?.[1] ?? "")).toContain("validation_report"); - expect(String(coordinator.injectEvent.mock.calls[3]?.[1] ?? "")).toContain("phase_transition"); - }); - - it("clips oversized routed messages and emits suppression summaries after rate limiting", () => { - vi.useFakeTimers(); - try { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - const largeEvent = { - type: "orchestrator-run-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.000Z", - reason: "validation_contract_unfulfilled", - } as any; - routeEventToCoordinator(coordinator, largeEvent, { - graph: { - run: { id: "run-1", status: "active", metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "step-1", - status: "failed", - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "failed", - resultEnvelope: { - summary: "x".repeat(10_000), - }, - }, - ], - claims: [], - } as any, - }); - const firstMessage = coordinator.injectEvent.mock.calls[0]?.[1] as string; - expect(firstMessage.length).toBeLessThanOrEqual(6_000); - expect(firstMessage).toContain("[router-truncated]"); - - for (let index = 0; index < 30; index += 1) { - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: `step-${index}`, - at: "2026-03-03T00:00:00.000Z", - reason: "validation_self_check_reminder", - } as any); - } - expect(coordinator.injectEvent).toHaveBeenCalledTimes(24); - - vi.advanceTimersByTime(1_001); - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-next", - at: "2026-03-03T00:00:01.100Z", - reason: "validation_self_check_reminder", - } as any); - - expect(coordinator.injectEvent).toHaveBeenCalledTimes(25); - const resumedMessage = coordinator.injectEvent.mock.calls[24]?.[1] as string; - expect(resumedMessage).toContain("Suppressed"); - } finally { - vi.useRealTimers(); - } - }); - - it("routes critical runtime reasons even after non-critical events hit the rate limit", () => { - vi.useFakeTimers(); - try { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - for (let index = 0; index < 24; index += 1) { - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: `step-${index}`, - at: "2026-03-03T00:00:00.000Z", - reason: "validation_self_check_reminder", - } as any); - } - expect(coordinator.injectEvent).toHaveBeenCalledTimes(24); - - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-over-limit", - at: "2026-03-03T00:00:00.100Z", - reason: "validation_self_check_reminder", - } as any); - expect(coordinator.injectEvent).toHaveBeenCalledTimes(24); - - routeEventToCoordinator(coordinator, { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-critical", - attemptId: "attempt-critical", - at: "2026-03-03T00:00:00.101Z", - reason: "failed", - } as any); - - expect(coordinator.injectEvent).toHaveBeenCalledTimes(25); - const criticalMessage = coordinator.injectEvent.mock.calls[24]?.[1] as string; - expect(criticalMessage).toContain("Suppressed"); - } finally { - vi.useRealTimers(); - } - }); - - it("adds a development transition hint when a planning-labeled step completes", () => { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - routeEventToCoordinator( - coordinator, - { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.000Z", - reason: "attempt_completed", - } as any, - { - graph: { - run: { - status: "active", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - }, - }, - steps: [ - { - id: "step-1", - stepKey: "worker_plan", - status: "succeeded", - metadata: { - phaseKey: "planning", - phaseName: "Planning", - }, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "succeeded", - resultEnvelope: { - summary: "Research complete.", - }, - }, - ], - claims: [], - } as any, - }, - ); - - const message = coordinator.injectEvent.mock.calls[0]?.[1] as string; - expect(message).toContain("set_current_phase"); - expect(message).toContain("development"); - }); - - it("adds a complete_mission hint when all tracked steps are terminal", () => { - const coordinator = { - injectEvent: vi.fn(), - } as any; - - routeEventToCoordinator( - coordinator, - { - type: "orchestrator-step-updated", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - at: "2026-03-03T00:00:00.000Z", - reason: "attempt_completed", - } as any, - { - graph: { - run: { - status: "active", - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - }, - }, - steps: [ - { - id: "step-1", - stepKey: "worker_impl", - status: "succeeded", - metadata: { - phaseKey: "development", - phaseName: "Development", - }, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "succeeded", - resultEnvelope: { - summary: "Implementation complete.", - }, - }, - ], - claims: [], - } as any, - }, - ); - - const message = coordinator.injectEvent.mock.calls[0]?.[1] as string; - expect(message).toContain("complete_mission"); - expect(message).toContain("all tracked steps are terminal"); - }); - - it("replays queued worker messages when agent chat reports a terminal failure state", async () => { - const replayQueuedWorkerMessages = vi.fn().mockResolvedValue(undefined); - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - sessionSignalQueues: new Map(), - } as any; - - onAgentChatEvent( - ctx, - { - sessionId: "session-1", - event: { - type: "status", - turnStatus: "failed", - }, - } as any, - { replayQueuedWorkerMessages }, - ); - - const queued = ctx.sessionSignalQueues.get("session-1"); - await queued; - - expect(replayQueuedWorkerMessages).toHaveBeenCalledWith(expect.objectContaining({ - reason: "agent_chat_event:status", - sessionId: "session-1", - })); - expect(ctx.sessionSignalQueues.size).toBe(0); - }); - - it("cleans up session queue entries after runtime signal processing settles", async () => { - const processSessionRuntimeSignal = vi.fn().mockResolvedValue(undefined); - const replayQueuedWorkerMessages = vi.fn().mockResolvedValue(undefined); - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - sessionRuntimeSignals: new Map(), - sessionSignalQueues: new Map(), - } as any; - - onSessionRuntimeSignal( - ctx, - { - sessionId: "session-2", - runtimeState: "running", - } as any, - { processSessionRuntimeSignal, replayQueuedWorkerMessages }, - ); - - const queued = ctx.sessionSignalQueues.get("session-2"); - await queued; - - expect(processSessionRuntimeSignal).toHaveBeenCalledTimes(1); - expect(replayQueuedWorkerMessages).toHaveBeenCalledWith(expect.objectContaining({ - reason: "runtime_signal", - sessionId: "session-2", - })); - expect(ctx.sessionSignalQueues.size).toBe(0); - expect(ctx.sessionRuntimeSignals.get("session-2")?.runtimeState).toBe("running"); - }); - - it("replays queued worker messages when agent chat reports an error event", async () => { - const replayQueuedWorkerMessages = vi.fn().mockResolvedValue(undefined); - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - sessionSignalQueues: new Map(), - } as any; - - onAgentChatEvent( - ctx, - { - sessionId: "session-err", - event: { - type: "error", - message: "worker crashed", - }, - } as any, - { replayQueuedWorkerMessages }, - ); - - const queued = ctx.sessionSignalQueues.get("session-err"); - await queued; - - expect(replayQueuedWorkerMessages).toHaveBeenCalledWith(expect.objectContaining({ - reason: "agent_chat_event:error", - sessionId: "session-err", - })); - expect(ctx.sessionSignalQueues.size).toBe(0); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/runtimeEventRouter.ts b/apps/desktop/src/main/services/orchestrator/runtimeEventRouter.ts deleted file mode 100644 index c8403fc3a..000000000 --- a/apps/desktop/src/main/services/orchestrator/runtimeEventRouter.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * runtimeEventRouter.ts - * - * Event dispatch helpers: onSessionRuntimeSignal, onAgentChatEvent, - * buildRunStateSnapshot, routeEventToCoordinator. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import type { - OrchestratorContext, - SessionRuntimeSignal, - AgentChatEventEnvelope, - OrchestratorRuntimeEvent, - OrchestratorRunGraph, -} from "./orchestratorContext"; -import { - nowIso, - parseTerminalRuntimeState, - SESSION_SIGNAL_RETENTION_MS, -} from "./orchestratorContext"; -import type { MetaReasonerRunState } from "./metaReasoner"; - -type ReplayQueuedWorkerMessagesResult = { - delivered: number; - failed: number; - queued: number; -}; - -// ── Session Runtime Signal Processing ──────────────────────────── - -function trackSessionQueue( - ctx: OrchestratorContext, - sessionId: string, - work: Promise<void>, -): void { - let tracked: Promise<void>; - tracked = work.finally(() => { - if (ctx.sessionSignalQueues.get(sessionId) === tracked) { - ctx.sessionSignalQueues.delete(sessionId); - } - }); - ctx.sessionSignalQueues.set(sessionId, tracked); -} - -/** - * Queue a session runtime signal for serial processing. - * The actual signal processing is delegated to the provided handler. - */ -export function onSessionRuntimeSignal( - ctx: OrchestratorContext, - signal: SessionRuntimeSignal, - deps: { - processSessionRuntimeSignal: (signal: SessionRuntimeSignal) => Promise<void>; - replayQueuedWorkerMessages?: ( - args: { reason: string; sessionId?: string | null; missionId?: string | null } - ) => Promise<void | ReplayQueuedWorkerMessagesResult>; - } -): void { - const sessionId = String(signal.sessionId ?? "").trim(); - if (!sessionId.length) return; - const runtimeState = parseTerminalRuntimeState(signal.runtimeState) ?? "running"; - const normalizedSignal: SessionRuntimeSignal = { - ...signal, - sessionId, - runtimeState, - lastOutputPreview: typeof signal.lastOutputPreview === "string" ? signal.lastOutputPreview : null, - at: signal.at ?? nowIso() - }; - - ctx.sessionRuntimeSignals.set(sessionId, normalizedSignal); - - const previous = ctx.sessionSignalQueues.get(sessionId) ?? Promise.resolve(); - const next = previous - .catch(() => {}) - .then(() => { - if (ctx.disposed.current) return; - return deps.processSessionRuntimeSignal(normalizedSignal) - .then(() => { - if (ctx.disposed.current || !deps.replayQueuedWorkerMessages) return; - return deps.replayQueuedWorkerMessages({ - reason: "runtime_signal", - sessionId, - }).then(() => undefined); - }); - }) - .catch((error) => { - ctx.logger.debug("ai_orchestrator.session_signal_processing_failed", { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - }); - trackSessionQueue(ctx, sessionId, next); -} - -/** - * Handle a generic agent chat event. - */ -export function onAgentChatEvent( - ctx: OrchestratorContext, - envelope: AgentChatEventEnvelope, - deps: { - replayQueuedWorkerMessages: ( - args: { reason: string; missionId?: string | null; sessionId?: string | null } - ) => Promise<void | ReplayQueuedWorkerMessagesResult>; - } -): void { - const sessionId = String(envelope.sessionId ?? "").trim(); - if (!sessionId.length) return; - - const event = envelope.event; - const turnStatus = typeof (event as any).turnStatus === "string" ? (event as any).turnStatus.trim().toLowerCase() : ""; - const shouldReplay = - event.type === "done" || - event.type === "error" || - (event.type === "status" && ( - turnStatus === "completed" - || turnStatus === "failed" - || turnStatus === "error" - || turnStatus === "errored" - || turnStatus === "interrupted" - || turnStatus === "cancelled" - || turnStatus === "canceled" - || turnStatus === "stopped" - )); - - const previous = ctx.sessionSignalQueues.get(sessionId) ?? Promise.resolve(); - const next = previous - .catch(() => {}) - .then(() => { - if (ctx.disposed.current) return; - if (shouldReplay) { - return deps.replayQueuedWorkerMessages({ - reason: `agent_chat_event:${event.type}`, - sessionId, - }).catch((error: unknown) => { - ctx.logger.debug("ai_orchestrator.worker_delivery_chat_event_replay_failed", { - sessionId, - eventType: event.type, - error: error instanceof Error ? error.message : String(error) - }); - }).then(() => undefined); - } - }) - .catch((error) => { - ctx.logger.debug("ai_orchestrator.agent_chat_event_processing_failed", { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - }); - trackSessionQueue(ctx, sessionId, next); -} - -/** - * Build a run state snapshot for meta-reasoner fan-out analysis. - */ -export function buildRunStateSnapshot(ctx: OrchestratorContext, runId: string): MetaReasonerRunState { - const graph = ctx.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - const activeAgentCount = graph.attempts.filter((a) => a.status === "running").length; - const runMeta = graph.run.metadata ?? {}; - const autopilot = typeof runMeta.autopilot === "object" && runMeta.autopilot && !Array.isArray(runMeta.autopilot) - ? (runMeta.autopilot as Record<string, unknown>) - : {}; - const rawParallelismCap = Number(autopilot.parallelismCap ?? 4); - const normalizedParallelismCap = Number.isFinite(rawParallelismCap) ? Math.floor(rawParallelismCap) : 4; - const parallelismCap = Math.max(1, Math.min(32, normalizedParallelismCap)); - - const runningLaneIds = new Set( - graph.steps.filter((s) => s.status === "running" && s.laneId).map((s) => s.laneId!) - ); - const allLaneIds = [...new Set(graph.steps.map((s) => s.laneId).filter((id): id is string => id != null))]; - const availableLanes = allLaneIds.filter((id) => !runningLaneIds.has(id)); - - const fileOwnershipMap: Record<string, string> = {}; - for (const claim of graph.claims) { - if (claim.state === "active" && claim.scopeKind === "file") { - fileOwnershipMap[claim.scopeValue] = claim.ownerId; - } - } - - return { activeAgentCount, parallelismCap, availableLanes, fileOwnershipMap }; -} - -/** - * Prune stale session runtime signals. - */ -export function pruneSessionRuntimeSignals(ctx: OrchestratorContext): void { - const now = Date.now(); - for (const [sessionId, signal] of ctx.sessionRuntimeSignals.entries()) { - const atMs = Date.parse(signal.at); - if (!Number.isFinite(atMs) || now - atMs > SESSION_SIGNAL_RETENTION_MS) { - ctx.sessionRuntimeSignals.delete(sessionId); - } - } -} - -// ── Coordinator Agent Event Routing ────────────────────────────── - -import type { CoordinatorAgent } from "./coordinatorAgent"; -import { formatRuntimeEvent, formatStepCompletion, formatStepFailure } from "./coordinatorEventFormatter"; - -const MAX_ROUTED_COORDINATOR_MESSAGE_CHARS = 6000; -const COORDINATOR_EVENT_DEDUPE_WINDOW_MS = 750; -const COORDINATOR_ROUTE_RATE_WINDOW_MS = 1_000; -const COORDINATOR_ROUTE_RATE_LIMIT = 24; -const COORDINATOR_ROUTING_CRITICAL_REASONS = new Set([ - "finalized", - "attempt_completed", - "completed", - "failed", - "skipped", - "intervention_opened", - "intervention_resolved", -]); -const COORDINATOR_IMPORTANT_RUNTIME_REASONS = new Set([ - "attempt_completed", - "completed", - "failed", - "skipped", - "finalized", - "delivery_failed", - "manual_intervention_required", - "manual_pause", - "manual_pause_for_review", - "manual_step_requires_operator", - "milestone_ready_validation_required", - "no_output_after_startup", - "phase_transition", - "question_answered_resume", - "required_validation_gate_blocked", - "required_validation_missing", - "resume_recovered", - "retry_exhausted", - "run_reopened", - "startup_verification_warning", - "validation_auto_spawned", - "validation_contract_unfulfilled", - "validation_gate_blocked", - "validation_report", - "validation_retry_exhausted", - "validation_self_check_reminder", - "worker_result_report", - "worker_status_report", - "finalization_queue_landed", -]); - -type CoordinatorRouteGuardState = { - lastFingerprint: string | null; - lastFingerprintAtMs: number; - routeWindowStartedAtMs: number; - routedInWindow: number; - suppressedCount: number; -}; - -const coordinatorRouteGuards = new WeakMap<CoordinatorAgent, CoordinatorRouteGuardState>(); - -function getCoordinatorRouteGuardState(coordinator: CoordinatorAgent): CoordinatorRouteGuardState { - const existing = coordinatorRouteGuards.get(coordinator); - if (existing) return existing; - const created: CoordinatorRouteGuardState = { - lastFingerprint: null, - lastFingerprintAtMs: 0, - routeWindowStartedAtMs: 0, - routedInWindow: 0, - suppressedCount: 0, - }; - coordinatorRouteGuards.set(coordinator, created); - return created; -} - -function clipRoutedCoordinatorMessage(message: string): string { - const normalized = message.trim(); - if (normalized.length <= MAX_ROUTED_COORDINATOR_MESSAGE_CHARS) return normalized; - const suffix = "\n[router-truncated]"; - return `${normalized.slice(0, MAX_ROUTED_COORDINATOR_MESSAGE_CHARS - suffix.length)}${suffix}`; -} - -/** - * Route a runtime event to a CoordinatorAgent instance. - * Formats the event into tiered context and injects it. - */ -export function routeEventToCoordinator( - coordinator: CoordinatorAgent, - event: OrchestratorRuntimeEvent, - context?: { graph?: OrchestratorRunGraph }, -): void { - const normalizedReason = String(event.reason ?? "").trim().toLowerCase(); - if (event.type === "orchestrator-claim-updated") { - return; - } - if (normalizedReason.length > 0 && !COORDINATOR_IMPORTANT_RUNTIME_REASONS.has(normalizedReason)) { - return; - } - const resolvedStep = - context?.graph && event.stepId - ? context.graph.steps.find((candidate) => candidate.id === event.stepId) - : undefined; - const resolvedAttempt = - context?.graph && event.attemptId - ? context.graph.attempts.find((candidate) => candidate.id === event.attemptId) - : undefined; - const formatted = - context?.graph && resolvedStep && (event.reason === "attempt_completed" || event.reason === "skipped") - ? formatStepCompletion(resolvedStep, resolvedAttempt ?? null, context.graph) - : context?.graph && resolvedStep && event.reason === "failed" - ? formatStepFailure(resolvedStep, resolvedAttempt ?? null, resolvedAttempt?.errorMessage ?? "unknown error") - : formatRuntimeEvent(event, { - graph: context?.graph, - step: resolvedStep, - attempt: resolvedAttempt, - }); - let message = formatted.digest - ? `${formatted.summary}\n${formatted.digest}` - : formatted.summary; - const nowMs = Date.now(); - const state = getCoordinatorRouteGuardState(coordinator); - const fingerprint = `${event.type}:${event.reason}:${event.stepId ?? ""}:${event.attemptId ?? ""}:${message.slice(0, 220)}`; - const isCritical = COORDINATOR_ROUTING_CRITICAL_REASONS.has(String(event.reason ?? "").trim().toLowerCase()); - const isDuplicate = - state.lastFingerprint === fingerprint - && nowMs - state.lastFingerprintAtMs < COORDINATOR_EVENT_DEDUPE_WINDOW_MS; - if (isDuplicate && !isCritical) { - state.lastFingerprint = fingerprint; - state.lastFingerprintAtMs = nowMs; - state.suppressedCount += 1; - return; - } - - if (nowMs - state.routeWindowStartedAtMs >= COORDINATOR_ROUTE_RATE_WINDOW_MS) { - state.routeWindowStartedAtMs = nowMs; - state.routedInWindow = 0; - } - if (state.routedInWindow >= COORDINATOR_ROUTE_RATE_LIMIT && !isCritical) { - state.lastFingerprint = fingerprint; - state.lastFingerprintAtMs = nowMs; - state.suppressedCount += 1; - return; - } - - state.lastFingerprint = fingerprint; - state.lastFingerprintAtMs = nowMs; - state.routedInWindow += 1; - - if (state.suppressedCount > 0) { - message += `\n[router] Suppressed ${state.suppressedCount} repetitive runtime event(s).`; - state.suppressedCount = 0; - } - - coordinator.injectEvent(event, clipRoutedCoordinatorMessage(message)); -} diff --git a/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.test.ts b/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.test.ts deleted file mode 100644 index 1883432cc..000000000 --- a/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { readDocPaths } from "./stepPolicyResolver"; - -const tempRoots: string[] = []; - -function makeTempProject(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-step-policy-")); - tempRoots.push(root); - return root; -} - -function writeFile(root: string, relPath: string, content = "doc\n") { - const filePath = path.join(root, relPath); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, "utf8"); -} - -afterEach(() => { - for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -describe("readDocPaths", () => { - it("ignores ADE internals and nested lane docs while keeping project docs", () => { - const root = makeTempProject(); - writeFile(root, "README.md"); - writeFile(root, "docs/overview.md"); - writeFile(root, ".ade/worktrees/old-lane/README.md"); - writeFile(root, ".ade/worktrees/old-lane/docs/ARCHITECTURE.md"); - writeFile(root, ".ade/artifacts/context.md"); - - const relPaths = readDocPaths(root).map((filePath) => path.relative(root, filePath).replace(/\\/g, "/")); - - expect(relPaths).toContain("README.md"); - expect(relPaths).toContain("docs/overview.md"); - expect(relPaths.some((relPath) => relPath.startsWith(".ade/"))).toBe(false); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts b/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts deleted file mode 100644 index 9dc927a9d..000000000 --- a/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts +++ /dev/null @@ -1,375 +0,0 @@ -/** - * stepPolicyResolver.ts - * - * Step policy resolution, context policy composition, autopilot config parsing, - * and file claim normalization/matching extracted from orchestratorService.ts. - */ - -import path from "node:path"; -import type { - OrchestratorClaimScope, - OrchestratorContextPolicyProfile, - OrchestratorExecutorKind, - OrchestratorStep, - StartOrchestratorRunStepInput, -} from "../../../shared/types"; -import { - normalizeClaimScope, - normalizeExecutorKind, - asIntInRange, - DEFAULT_CONTEXT_POLICY, -} from "./orchestratorQueries"; - -// ── Step Policy Types ────────────────────────────────────────────── - -export type StepPolicy = { - docsMaxBytes?: number; - claimScopes?: Array<{ - scopeKind: OrchestratorClaimScope; - scopeValue: string; - ttlMs?: number; - }>; -}; - -export type AutopilotConfig = { - enabled: boolean; - executorKind: OrchestratorExecutorKind; - ownerId: string; - parallelismCap: number; -}; - -export type ResolvedOrchestratorRuntimeConfig = { - teammatePlanMode: "off" | "auto" | "required"; - maxParallelWorkers: number; - defaultMergePolicy: "sequential" | "batch-at-end" | "per-step"; - defaultConflictHandoff: "auto-resolve" | "ask-user" | "orchestrator-decides"; - workerHeartbeatIntervalMs: number; - workerHeartbeatTimeoutMs: number; - workerIdleTimeoutMs: number; - stepTimeoutDefaultMs: number; - maxRetriesPerStep: number; - contextPressureThreshold: number; - progressiveLoading: boolean; - maxTotalTokenBudget: number | null; - maxPerStepTokenBudget: number | null; - fileReservationGuardMode: "off" | "warn" | "block"; -}; - -export const DEFAULT_ORCHESTRATOR_RUNTIME_CONFIG: ResolvedOrchestratorRuntimeConfig = { - teammatePlanMode: "auto", - maxParallelWorkers: 4, - defaultMergePolicy: "sequential", - defaultConflictHandoff: "auto-resolve", - workerHeartbeatIntervalMs: 30_000, - workerHeartbeatTimeoutMs: 90_000, - workerIdleTimeoutMs: 300_000, - stepTimeoutDefaultMs: 300_000, - maxRetriesPerStep: 2, - contextPressureThreshold: 0.8, - progressiveLoading: true, - maxTotalTokenBudget: null, - maxPerStepTokenBudget: null, - fileReservationGuardMode: "warn" -}; - -// ── Step Policy Resolution ───────────────────────────────────────── - -function parseClaimScopes( - raw: unknown[] -): Array<{ scopeKind: OrchestratorClaimScope; scopeValue: string; ttlMs?: number }> { - return raw - .map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; - const obj = entry as Record<string, unknown>; - const scopeKind = normalizeClaimScope(String(obj.scopeKind ?? "lane")); - const scopeValue = String(obj.scopeValue ?? "").trim(); - if (!scopeValue) return null; - const ttlRaw = Number(obj.ttlMs ?? NaN); - const normalized: { scopeKind: OrchestratorClaimScope; scopeValue: string; ttlMs?: number } = { - scopeKind, - scopeValue - }; - if (Number.isFinite(ttlRaw) && ttlRaw > 0) { - normalized.ttlMs = Math.floor(ttlRaw); - } - return normalized; - }) - .filter((entry): entry is { scopeKind: OrchestratorClaimScope; scopeValue: string; ttlMs?: number } => entry != null); -} - -export function resolveStepPolicy(step: OrchestratorStep): StepPolicy { - const metadata = step.metadata ?? {}; - const rawPolicy = metadata.policy; - if (!rawPolicy || typeof rawPolicy !== "object" || Array.isArray(rawPolicy)) return {}; - const record = rawPolicy as Record<string, unknown>; - const claimScopes = Array.isArray(record.claimScopes) - ? parseClaimScopes(record.claimScopes) - : undefined; - return { - docsMaxBytes: Number.isFinite(Number(record.docsMaxBytes)) ? Number(record.docsMaxBytes) : undefined, - claimScopes - }; -} - -export function resolveContextPolicy(args: { - stepPolicy: StepPolicy; -}): OrchestratorContextPolicyProfile { - return { - ...DEFAULT_CONTEXT_POLICY, - maxDocBytes: - typeof args.stepPolicy.docsMaxBytes === "number" && Number.isFinite(args.stepPolicy.docsMaxBytes) && args.stepPolicy.docsMaxBytes > 0 - ? Math.floor(args.stepPolicy.docsMaxBytes) - : DEFAULT_CONTEXT_POLICY.maxDocBytes - }; -} - -export function parseAutopilotConfig( - metadata: Record<string, unknown> | null | undefined, - defaultMaxParallelWorkers: number -): AutopilotConfig { - const fallback: AutopilotConfig = { - enabled: false, - executorKind: "manual", - ownerId: "orchestrator-autopilot", - parallelismCap: defaultMaxParallelWorkers - }; - if (!metadata) return fallback; - const raw = metadata.autopilot; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback; - const record = raw as Record<string, unknown>; - const executorKind = normalizeExecutorKind(String(record.executorKind ?? "manual")); - const enabled = record.enabled === true && executorKind !== "manual"; - const ownerId = String(record.ownerId ?? "").trim() || "orchestrator-autopilot"; - const parallelismCap = asIntInRange( - record.parallelismCap, - defaultMaxParallelWorkers, - 1, - 32 - ); - return { - enabled, - executorKind, - ownerId, - parallelismCap - }; -} - -export function parseStepPolicyFromMetadata(metadata: Record<string, unknown>): StartOrchestratorRunStepInput["policy"] | undefined { - const raw = metadata.policy; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - const record = raw as Record<string, unknown>; - const includeNarrative = record.includeNarrative === true; - const includeFullDocs = record.includeFullDocs === true; - const docsMaxBytes = Number(record.docsMaxBytes); - const claimScopes = Array.isArray(record.claimScopes) - ? parseClaimScopes(record.claimScopes) - : undefined; - return { - includeNarrative, - includeFullDocs, - docsMaxBytes: Number.isFinite(docsMaxBytes) && docsMaxBytes > 0 ? Math.floor(docsMaxBytes) : undefined, - claimScopes - }; -} - -export function parseStepAIPriority(step: OrchestratorStep): number | null { - const numeric = Number(step.metadata?.aiPriority ?? Number.NaN); - return Number.isFinite(numeric) ? numeric : null; -} - -export function readyStepOrderComparator(a: OrchestratorStep, b: OrchestratorStep) { - const aiPriorityA = parseStepAIPriority(a); - const aiPriorityB = parseStepAIPriority(b); - if (aiPriorityA != null || aiPriorityB != null) { - if (aiPriorityA == null) return 1; - if (aiPriorityB == null) return -1; - const aiPriorityDiff = aiPriorityB - aiPriorityA; - if (aiPriorityDiff !== 0) return aiPriorityDiff; - } - const createdOrderDiff = a.createdAt.localeCompare(b.createdAt); - if (createdOrderDiff !== 0) return createdOrderDiff; - return a.id.localeCompare(b.id); -} - -// ── File Claim Helpers ───────────────────────────────────────────── - -export function normalizeRepoRelativePath(projectRoot: string, rawPath: string): string | null { - let value = String(rawPath ?? "").trim(); - if (!value.length) return null; - if (path.isAbsolute(value)) { - value = path.relative(projectRoot, value); - } - value = value.replace(/\\/g, "/"); - value = path.posix.normalize(value); - while (value.startsWith("./")) value = value.slice(2); - if (!value.length || value === ".") return null; - if (value.startsWith("../")) return null; - return value; -} - -export function extractFileClaimPattern(scopeValue: string): string { - let value = String(scopeValue ?? "").trim(); - if (value.startsWith("pattern:")) value = value.slice("pattern:".length); - if (value.startsWith("glob:")) value = value.slice("glob:".length); - return value.trim(); -} - -export function normalizeFileClaimScopeValue(projectRoot: string, scopeValue: string): string | null { - let pattern = extractFileClaimPattern(scopeValue); - if (!pattern.length) return null; - pattern = pattern.replace(/\\/g, "/"); - if (pattern.startsWith("/")) { - pattern = pattern.slice(1); - } - if (pattern.endsWith("/")) { - pattern = `${pattern}**`; - } - const normalized = normalizeRepoRelativePath(projectRoot, pattern); - if (!normalized) return null; - return `glob:${normalized}`; -} - -function staticGlobPrefix(globPattern: string): string { - const wildcardIndex = globPattern.search(/[*?[\]]/); - if (wildcardIndex < 0) return globPattern; - return globPattern.slice(0, wildcardIndex); -} - -function globToRegExp(globPattern: string): RegExp { - const escaped = globPattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, "__ADE_GLOB_STAR__") - .replace(/\?/g, "__ADE_GLOB_Q__") - .replace(/__ADE_GLOB_STAR____ADE_GLOB_STAR__/g, ".*") - .replace(/__ADE_GLOB_STAR__/g, "[^/]*") - .replace(/__ADE_GLOB_Q__/g, "[^/]"); - return new RegExp(`^${escaped}$`); -} - -export function doesFileClaimMatchPath(scopeValue: string, repoPath: string): boolean { - const pattern = extractFileClaimPattern(scopeValue); - if (!pattern.length) return false; - try { - return globToRegExp(pattern).test(repoPath); - } catch { - return false; - } -} - -export function doFileClaimsOverlap(leftScopeValue: string, rightScopeValue: string): boolean { - const left = extractFileClaimPattern(leftScopeValue); - const right = extractFileClaimPattern(rightScopeValue); - if (!left.length || !right.length) return false; - if (left === right) return true; - - const leftWildcard = /[*?[\]]/.test(left); - const rightWildcard = /[*?[\]]/.test(right); - if (!leftWildcard && !rightWildcard) return left === right; - if (!leftWildcard) return doesFileClaimMatchPath(rightScopeValue, left); - if (!rightWildcard) return doesFileClaimMatchPath(leftScopeValue, right); - - const leftPrefix = staticGlobPrefix(left); - const rightPrefix = staticGlobPrefix(right); - if (leftPrefix.length > 0 && rightPrefix.length > 0) { - if (leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix)) return true; - const leftRoot = leftPrefix.split("/")[0] ?? ""; - const rightRoot = rightPrefix.split("/")[0] ?? ""; - if (leftRoot.length > 0 && leftRoot === rightRoot) return true; - return false; - } - - return true; -} - -// ── Docs Path Cache ──────────────────────────────────────────────── - -import fs from "node:fs"; - -const docPathsCache = new Map<string, { paths: string[]; expiresAt: number }>(); -const DOC_PATHS_CACHE_TTL_MS = 60_000; -const DOC_PRIORITY_REL_PATHS = [ - "README.md", - "CLAUDE.md", - "AGENTS.md", - "PRD.md", - "ARCHITECTURE.md", -]; -const DOC_SCAN_MAX_DEPTH = 4; -const DOC_SCAN_SKIP_DIRS = new Set([ - ".ade", - ".git", - "node_modules", - ".next", - ".nuxt", - "dist", - "build", - "coverage", - ".turbo", - ".cache", - ".idea", - ".vscode" -]); -const DOC_FILE_EXT_RE = /\.(md|mdx|txt|rst)$/i; -const DOC_FILE_NAME_HINT_RE = /(readme|architecture|arch|prd|design|spec|requirement|guide|overview|plan|context|claude|agents)/i; -const DOC_SCAN_MAX_PATHS = 80; - -export function readDocPaths(projectRoot: string): string[] { - const cached = docPathsCache.get(projectRoot); - if (cached && Date.now() < cached.expiresAt) return cached.paths; - - const priorityPaths: string[] = []; - const prioritySet = new Set<string>(); - const scannedSet = new Set<string>(); - - const addPriorityPath = (absPath: string) => { - try { - const stat = fs.statSync(absPath); - if (!stat.isFile()) return; - const normalized = path.normalize(absPath); - if (prioritySet.has(normalized)) return; - prioritySet.add(normalized); - priorityPaths.push(normalized); - } catch { - // ignore missing/unreadable files - } - }; - - for (const relPath of DOC_PRIORITY_REL_PATHS) { - addPriorityPath(path.join(projectRoot, relPath)); - } - - const walk = (root: string, depth: number) => { - if (depth < 0) return; - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (entry.name.startsWith(".")) continue; - if (DOC_SCAN_SKIP_DIRS.has(entry.name)) continue; - const abs = path.join(root, entry.name); - if (entry.isDirectory()) { - walk(abs, depth - 1); - continue; - } - if (!entry.isFile()) continue; - if (!DOC_FILE_EXT_RE.test(entry.name)) continue; - const rel = path.relative(projectRoot, abs).replace(/\\/g, "/"); - const inDocsDir = /(^|\/)docs\//i.test(rel); - const hinted = - DOC_FILE_NAME_HINT_RE.test(entry.name) - || inDocsDir; - if (!hinted) continue; - const normalized = path.normalize(abs); - if (!prioritySet.has(normalized)) scannedSet.add(normalized); - } - }; - walk(projectRoot, DOC_SCAN_MAX_DEPTH); - - const scannedPaths = [...scannedSet].sort((a, b) => a.localeCompare(b)); - const paths = [...priorityPaths, ...scannedPaths].slice(0, DOC_SCAN_MAX_PATHS); - docPathsCache.set(projectRoot, { paths, expiresAt: Date.now() + DOC_PATHS_CACHE_TTL_MS }); - return paths; -} diff --git a/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts b/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts deleted file mode 100644 index 7568839c5..000000000 --- a/apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * teamRuntimeConfig.ts - * - * Team runtime configuration: template parsing, policy flags, role definitions, - * team runtime config resolution and normalization. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import type { - OrchestratorContext, -} from "./orchestratorContext"; -import { - isRecord, -} from "./orchestratorContext"; -import type { - TeamTemplate, - TeamRuntimeConfig, - RoleDefinition, - MissionPolicyFlags, - MissionAgentRuntimeConfig, -} from "../../../shared/types"; -import { getErrorMessage } from "../shared/utils"; - -// ── Constants ──────────────────────────────────────────────────── - -export const DEFAULT_MISSION_POLICY_FLAGS: MissionPolicyFlags = { - clarificationMode: "auto_if_uncertain", - maxClarificationQuestions: 5, - strictTdd: false, - requireValidatorPass: true, - maxParallelWorkers: 4, - riskApprovalMode: "confirm_high_risk" -}; - -export const REQUIRED_TEAM_CAPABILITIES = ["coordinator", "planner", "validator"] as const; - -export const DEFAULT_TEAM_TEMPLATE: TeamTemplate = { - id: "default-autonomy-template", - name: "Autonomous Team", - roles: [ - { - name: "coordinator", - description: "Mission lead that plans, delegates, and decides recovery strategy.", - capabilities: ["coordinator", "planner"], - maxInstances: 1, - toolProfile: { - allowedTools: [ - "spawn_worker", - "delegate_parallel", - "request_specialist", - "revise_plan", - "retry_step", - "skip_step", - "read_mission_status", - "message_worker", - "read_file", - "search_files", - "get_project_context", - "report_status", - "report_result", - "report_validation", - "update_tool_profiles", - "transfer_lane" - ] - } - }, - { - name: "implementer", - description: "Executes implementation tasks and reports structured progress/results.", - capabilities: ["implementation"], - maxInstances: 12 - }, - { - name: "validator", - description: "Validates outputs at gates and returns actionable remediation guidance.", - capabilities: ["validator", "review", "testing"], - maxInstances: 4 - } - ], - policyDefaults: DEFAULT_MISSION_POLICY_FLAGS, - constraints: { - maxWorkers: 20, - requiredRoles: [...REQUIRED_TEAM_CAPABILITIES] - } -}; - -// ── Agent runtime flag normalization ───────────────────────────── - -/** Normalize optional boolean agent runtime flags to concrete booleans (default true). */ -export function normalizeAgentRuntimeFlags( - raw: Partial<MissionAgentRuntimeConfig> | undefined | null -): MissionAgentRuntimeConfig { - return { - allowParallelAgents: raw?.allowParallelAgents !== false, - allowSubAgents: raw?.allowSubAgents !== false, - allowClaudeAgentTeams: raw?.allowClaudeAgentTeams !== false, - }; -} - -// ── Pure Parsing Functions ──────────────────────────────────────── - -export function toClampedToolProfileMap(value: unknown): TeamRuntimeConfig["toolProfiles"] { - if (!isRecord(value)) return undefined; - const out: Record<string, { allowedTools: string[]; blockedTools?: string[]; notes?: string }> = {}; - for (const [key, raw] of Object.entries(value)) { - if (!isRecord(raw)) continue; - const allowedTools = Array.isArray(raw.allowedTools) - ? raw.allowedTools.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - if (!allowedTools.length) continue; - const blockedTools = Array.isArray(raw.blockedTools) - ? raw.blockedTools.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : undefined; - out[key] = { - allowedTools, - ...(blockedTools && blockedTools.length > 0 ? { blockedTools } : {}), - ...(typeof raw.notes === "string" && raw.notes.trim().length > 0 ? { notes: raw.notes.trim() } : {}) - }; - } - return Object.keys(out).length > 0 ? out : undefined; -} - -export function parsePolicyFlags(value: unknown): MissionPolicyFlags | undefined { - if (!isRecord(value)) return undefined; - const maxQuestions = Number(value.maxClarificationQuestions); - const maxParallelWorkers = Number(value.maxParallelWorkers); - return { - clarificationMode: - value.clarificationMode === "always" || - value.clarificationMode === "auto_if_uncertain" || - value.clarificationMode === "off" - ? value.clarificationMode - : undefined, - maxClarificationQuestions: Number.isFinite(maxQuestions) ? Math.max(1, Math.min(20, Math.floor(maxQuestions))) : undefined, - strictTdd: typeof value.strictTdd === "boolean" ? value.strictTdd : undefined, - requireValidatorPass: typeof value.requireValidatorPass === "boolean" ? value.requireValidatorPass : undefined, - maxParallelWorkers: Number.isFinite(maxParallelWorkers) ? Math.max(1, Math.min(32, Math.floor(maxParallelWorkers))) : undefined, - riskApprovalMode: - value.riskApprovalMode === "auto" || - value.riskApprovalMode === "confirm_high_risk" || - value.riskApprovalMode === "confirm_all" - ? value.riskApprovalMode - : undefined - }; -} - -export function parseRoleDefinition(value: unknown): RoleDefinition | null { - if (!isRecord(value)) return null; - const name = typeof value.name === "string" ? value.name.trim() : ""; - if (!name.length) return null; - const description = typeof value.description === "string" ? value.description.trim() : ""; - const capabilities = Array.isArray(value.capabilities) - ? value.capabilities.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : []; - const maxInstancesRaw = Number(value.maxInstances); - return { - name, - description: description.length ? description : `${name} role`, - capabilities, - ...(Number.isFinite(maxInstancesRaw) && maxInstancesRaw > 0 - ? { maxInstances: Math.max(1, Math.min(100, Math.floor(maxInstancesRaw))) } - : {}) - }; -} - -export function parseTeamTemplate(value: unknown): TeamTemplate | null { - if (!isRecord(value)) return null; - const id = typeof value.id === "string" && value.id.trim().length > 0 - ? value.id.trim() - : DEFAULT_TEAM_TEMPLATE.id; - const name = typeof value.name === "string" && value.name.trim().length > 0 - ? value.name.trim() - : DEFAULT_TEAM_TEMPLATE.name; - const roles = Array.isArray(value.roles) - ? value.roles.map((entry) => parseRoleDefinition(entry)).filter((entry): entry is RoleDefinition => !!entry) - : []; - const constraints = isRecord(value.constraints) ? value.constraints : null; - const maxWorkersRaw = Number(constraints?.maxWorkers); - const requiredRoles = Array.isArray(constraints?.requiredRoles) - ? constraints.requiredRoles.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0) - : [...REQUIRED_TEAM_CAPABILITIES]; - return { - id, - name, - roles: roles.length > 0 ? roles : DEFAULT_TEAM_TEMPLATE.roles, - policyDefaults: { - ...DEFAULT_MISSION_POLICY_FLAGS, - ...(parsePolicyFlags(value.policyDefaults) ?? {}) - }, - constraints: { - maxWorkers: Number.isFinite(maxWorkersRaw) ? Math.max(1, Math.min(100, Math.floor(maxWorkersRaw))) : DEFAULT_TEAM_TEMPLATE.constraints.maxWorkers, - requiredRoles - } - }; -} - -export function missingRequiredCapabilities(template: TeamTemplate): string[] { - const roleNames = new Set(template.roles.map((role) => role.name.toLowerCase())); - const roleCapabilities = new Set( - template.roles.flatMap((role) => role.capabilities.map((capability) => capability.toLowerCase())) - ); - const required = template.constraints.requiredRoles.length - ? template.constraints.requiredRoles - : [...REQUIRED_TEAM_CAPABILITIES]; - const missing: string[] = []; - for (const requiredEntry of required) { - const normalized = requiredEntry.toLowerCase(); - if (roleNames.has(normalized)) continue; - if (roleCapabilities.has(normalized)) continue; - missing.push(requiredEntry); - } - return missing; -} - -// ── Context-dependent Functions ────────────────────────────────── - -/** Resolve team runtime config from mission launch metadata */ -export function resolveMissionTeamRuntime( - ctx: OrchestratorContext, - missionId: string -): TeamRuntimeConfig | null { - try { - const row = ctx.db.get<{ metadata_json: string | null }>( - "select metadata_json from missions where id = ? limit 1", - [missionId] - ); - if (row?.metadata_json) { - const metadata = JSON.parse(row.metadata_json); - const launch = isRecord(metadata.launch) ? metadata.launch : null; - const teamRuntime = launch && isRecord(launch.teamRuntime) ? launch.teamRuntime : null; - if (teamRuntime && teamRuntime.enabled === true) { - const parsedTemplate = parseTeamTemplate(teamRuntime.template); - const template = parsedTemplate ?? DEFAULT_TEAM_TEMPLATE; - const missing = missingRequiredCapabilities(template); - if (missing.length > 0) { - throw new Error(`teamRuntime template missing required roles/capabilities: ${missing.join(", ")}`); - } - const teammateCount = typeof teamRuntime.teammateCount === "number" - ? Math.max(0, Math.min(20, Math.floor(teamRuntime.teammateCount))) - : 2; - const boundedTeammateCount = Math.min(teammateCount, Math.max(0, template.constraints.maxWorkers - 1)); - return { - enabled: true, - targetProvider: (teamRuntime.targetProvider === "claude" || teamRuntime.targetProvider === "codex") ? teamRuntime.targetProvider : "auto", - teammateCount: boundedTeammateCount, - ...normalizeAgentRuntimeFlags(teamRuntime as Partial<MissionAgentRuntimeConfig>), - template, - toolProfiles: toClampedToolProfileMap(teamRuntime.toolProfiles), - policyOverrides: parsePolicyFlags(teamRuntime.policyOverrides) - }; - } - } - } catch (error) { - ctx.logger.warn("ai_orchestrator.team_runtime_config_invalid", { - missionId, - error: getErrorMessage(error) - }); - throw error; - } - return null; -} - -export function normalizeTeamRuntimeConfig(_missionId: string, config: TeamRuntimeConfig): TeamRuntimeConfig { - if (!config.enabled) return config; - const template = parseTeamTemplate(config.template) ?? DEFAULT_TEAM_TEMPLATE; - const missing = missingRequiredCapabilities(template); - if (missing.length > 0) { - throw new Error(`teamRuntime template missing required roles/capabilities: ${missing.join(", ")}`); - } - const teammateCount = Math.max( - 0, - Math.min( - 20, - Math.min( - Number.isFinite(Number(config.teammateCount)) ? Math.floor(Number(config.teammateCount)) : 2, - Math.max(0, template.constraints.maxWorkers - 1) - ) - ) - ); - const policyOverrides = { - ...DEFAULT_MISSION_POLICY_FLAGS, - ...(config.policyOverrides ?? {}) - }; - return { - ...config, - targetProvider: - config.targetProvider === "claude" || config.targetProvider === "codex" || config.targetProvider === "auto" - ? config.targetProvider - : "auto", - teammateCount, - ...normalizeAgentRuntimeFlags(config), - template, - policyOverrides - }; -} diff --git a/apps/desktop/src/main/services/orchestrator/teamRuntimeState.ts b/apps/desktop/src/main/services/orchestrator/teamRuntimeState.ts deleted file mode 100644 index 185e48e17..000000000 --- a/apps/desktop/src/main/services/orchestrator/teamRuntimeState.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * teamRuntimeState.ts - * - * Team runtime state management: register/update/query team members, - * initialize/update/get team runtime state. - * - * Extracted from aiOrchestratorService.ts and now the single authoritative - * read/write path for orchestrator team-member runtime state. - */ - -import type { - OrchestratorContext, -} from "./orchestratorContext"; -import { - nowIso, - parseJsonArray, - parseJsonRecord, -} from "./orchestratorContext"; -import type { - OrchestratorTeamMember, - OrchestratorTeamRuntimeState, -} from "../../../shared/types"; - -function normalizeTeamMemberSource( - member: Pick<OrchestratorTeamMember, "source" | "role" | "metadata"> -): OrchestratorTeamMember["source"] { - if (member.source === "ade-worker" || member.source === "ade-subagent" || member.source === "claude-native") { - return member.source; - } - const metadata = member.metadata ?? {}; - const metadataSource = typeof metadata.source === "string" ? metadata.source.trim() : ""; - if (metadataSource === "ade-worker" || metadataSource === "ade-subagent" || metadataSource === "claude-native") { - return metadataSource; - } - const isSubAgent = metadata.isSubAgent === true || metadata.parentWorkerId != null; - return isSubAgent || member.role === "teammate" ? "ade-subagent" : "ade-worker"; -} - -function normalizeParentWorkerId(member: Pick<OrchestratorTeamMember, "parentWorkerId" | "metadata">): string | null { - if (typeof member.parentWorkerId === "string" && member.parentWorkerId.trim().length > 0) { - return member.parentWorkerId.trim(); - } - const metadata = member.metadata ?? {}; - if (typeof metadata.parentWorkerId === "string" && metadata.parentWorkerId.trim().length > 0) { - return metadata.parentWorkerId.trim(); - } - return null; -} - -// ── Team Member Functions ──────────────────────────────────────── - -export function registerTeamMember(ctx: OrchestratorContext, member: OrchestratorTeamMember): void { - const source = normalizeTeamMemberSource(member); - const parentWorkerId = normalizeParentWorkerId(member); - const metadata: Record<string, unknown> = { - ...(member.metadata ?? {}), - source, - ...(parentWorkerId ? { parentWorkerId } : {}), - }; - ctx.db.run( - `insert into orchestrator_team_members( - id, run_id, mission_id, provider, model, role, session_id, status, - claimed_task_ids_json, metadata_json, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - member.id, member.runId, member.missionId, member.provider, member.model, - member.role, member.sessionId, member.status, - JSON.stringify(member.claimedTaskIds), JSON.stringify(metadata), - member.createdAt, member.updatedAt - ] - ); -} - -export function updateTeamMemberStatus( - ctx: OrchestratorContext, - memberId: string, - updates: { - status?: OrchestratorTeamMember["status"]; - sessionId?: string | null; - claimedTaskIds?: string[]; - } -): void { - const now = nowIso(); - if (updates.status) { - ctx.db.run( - `update orchestrator_team_members set status = ?, updated_at = ? where id = ?`, - [updates.status, now, memberId] - ); - } - if (updates.sessionId !== undefined) { - ctx.db.run( - `update orchestrator_team_members set session_id = ?, updated_at = ? where id = ?`, - [updates.sessionId, now, memberId] - ); - } - if (updates.claimedTaskIds) { - ctx.db.run( - `update orchestrator_team_members set claimed_task_ids_json = ?, updated_at = ? where id = ?`, - [JSON.stringify(updates.claimedTaskIds), now, memberId] - ); - } -} - -export function getTeamMembersForRun(ctx: OrchestratorContext, runId: string): OrchestratorTeamMember[] { - const rows = ctx.db.all<{ - id: string; run_id: string; mission_id: string; provider: string; model: string; - role: string; session_id: string | null; status: string; - claimed_task_ids_json: string; metadata_json: string | null; - created_at: string; updated_at: string; - }>( - `select * from orchestrator_team_members where run_id = ? order by created_at asc`, - [runId] - ); - return rows.map((row) => { - const metadata = parseJsonRecord(row.metadata_json) ?? {}; - const sourceRaw = typeof metadata.source === "string" ? metadata.source.trim() : ""; - const source: OrchestratorTeamMember["source"] = - sourceRaw === "ade-worker" || sourceRaw === "ade-subagent" || sourceRaw === "claude-native" - ? sourceRaw - : (metadata.isSubAgent === true || typeof metadata.parentWorkerId === "string" || row.role === "teammate") - ? "ade-subagent" - : "ade-worker"; - const parentWorkerId = - typeof metadata.parentWorkerId === "string" && metadata.parentWorkerId.trim().length > 0 - ? metadata.parentWorkerId.trim() - : null; - return { - id: row.id, - runId: row.run_id, - missionId: row.mission_id, - provider: row.provider, - model: row.model, - role: row.role as OrchestratorTeamMember["role"], - source, - parentWorkerId, - sessionId: row.session_id, - status: row.status as OrchestratorTeamMember["status"], - claimedTaskIds: parseJsonArray(row.claimed_task_ids_json) as string[], - metadata, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - }); -} - -// ── Team Runtime State Functions ───────────────────────────────── - -export function initTeamRuntimeState( - ctx: OrchestratorContext, - runId: string, - coordinatorSessionId: string | null -): void { - const now = nowIso(); - ctx.db.run( - `insert into orchestrator_run_state( - run_id, phase, completion_requested, completion_validated, - last_validation_error, coordinator_session_id, teammate_ids_json, - created_at, updated_at - ) values (?, 'bootstrapping', 0, 0, null, ?, '[]', ?, ?) - on conflict(run_id) do update set - phase = 'bootstrapping', - completion_requested = 0, - completion_validated = 0, - coordinator_session_id = excluded.coordinator_session_id, - updated_at = excluded.updated_at`, - [runId, coordinatorSessionId, now, now] - ); - ctx.teamRuntimeStates.set(runId, { - runId, - phase: "bootstrapping", - completionRequested: false, - completionValidated: false, - lastValidationError: null, - coordinatorSessionId: coordinatorSessionId, - teammateIds: [], - createdAt: now, - updatedAt: now, - }); -} - -export function updateTeamRuntimePhase( - ctx: OrchestratorContext, - runId: string, - phase: OrchestratorTeamRuntimeState["phase"], - extra?: { - coordinatorSessionId?: string | null; - teammateIds?: string[]; - completionRequested?: boolean; - completionValidated?: boolean; - lastValidationError?: string | null; - } -): void { - const now = nowIso(); - const state = ctx.teamRuntimeStates.get(runId); - if (state) { - state.phase = phase; - state.updatedAt = now; - if (extra?.coordinatorSessionId !== undefined) state.coordinatorSessionId = extra.coordinatorSessionId; - if (extra?.teammateIds) state.teammateIds = extra.teammateIds; - if (extra?.completionRequested !== undefined) state.completionRequested = extra.completionRequested; - if (extra?.completionValidated !== undefined) state.completionValidated = extra.completionValidated; - if (extra?.lastValidationError !== undefined) state.lastValidationError = extra.lastValidationError; - } - ctx.db.run( - `update orchestrator_run_state set phase = ?, updated_at = ? where run_id = ?`, - [phase, now, runId] - ); - if (extra?.coordinatorSessionId !== undefined) { - ctx.db.run( - `update orchestrator_run_state set coordinator_session_id = ? where run_id = ?`, - [extra.coordinatorSessionId, runId] - ); - } - if (extra?.teammateIds) { - ctx.db.run( - `update orchestrator_run_state set teammate_ids_json = ? where run_id = ?`, - [JSON.stringify(extra.teammateIds), runId] - ); - } - if (extra?.completionRequested !== undefined) { - ctx.db.run( - `update orchestrator_run_state set completion_requested = ? where run_id = ?`, - [extra.completionRequested ? 1 : 0, runId] - ); - } - if (extra?.completionValidated !== undefined) { - ctx.db.run( - `update orchestrator_run_state set completion_validated = ? where run_id = ?`, - [extra.completionValidated ? 1 : 0, runId] - ); - } - if (extra?.lastValidationError !== undefined) { - ctx.db.run( - `update orchestrator_run_state set last_validation_error = ? where run_id = ?`, - [extra.lastValidationError, runId] - ); - } -} - -export function getTeamRuntimeStateForRun( - ctx: OrchestratorContext, - runId: string -): OrchestratorTeamRuntimeState | null { - const cached = ctx.teamRuntimeStates.get(runId); - if (cached) return cached; - const row = ctx.db.get<{ - run_id: string; phase: string; completion_requested: number; completion_validated: number; - last_validation_error: string | null; coordinator_session_id: string | null; - teammate_ids_json: string; created_at: string; updated_at: string; - }>( - `select * from orchestrator_run_state where run_id = ? limit 1`, - [runId] - ); - if (!row) return null; - const runtimeState: OrchestratorTeamRuntimeState = { - runId: row.run_id, - phase: row.phase as OrchestratorTeamRuntimeState["phase"], - completionRequested: row.completion_requested === 1, - completionValidated: row.completion_validated === 1, - lastValidationError: row.last_validation_error, - coordinatorSessionId: row.coordinator_session_id, - teammateIds: parseJsonArray(row.teammate_ids_json) as string[], - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - ctx.teamRuntimeStates.set(runId, runtimeState); - return runtimeState; -} diff --git a/apps/desktop/src/main/services/orchestrator/workerDeliveryService.test.ts b/apps/desktop/src/main/services/orchestrator/workerDeliveryService.test.ts deleted file mode 100644 index f8f399fdc..000000000 --- a/apps/desktop/src/main/services/orchestrator/workerDeliveryService.test.ts +++ /dev/null @@ -1,737 +0,0 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; -import path from "node:path"; -import { createRequire } from "node:module"; -import initSqlJs from "sql.js"; -import type { Database, SqlJsStatic } from "sql.js"; -import { getChatMessageById, sendThreadMessageCtx, upsertThread } from "./chatMessageService"; -import { - resolveWorkerDeliverySessionCtx, - routeMessageToCoordinatorCtx, - sendWorkerMessageToSessionWithStatusCtx, - upsertWorkerDeliveryInterventionCtx, -} from "./workerDeliveryService"; - -type SqlValue = string | number | null | Uint8Array; - -type AdeDb = { - run: (sql: string, params?: SqlValue[]) => void; - get: <T extends Record<string, unknown> = Record<string, unknown>>(sql: string, params?: SqlValue[]) => T | null; - all: <T extends Record<string, unknown> = Record<string, unknown>>(sql: string, params?: SqlValue[]) => T[]; -}; - -function mapExecRows(rows: { columns: string[]; values: unknown[][] }[]): Record<string, unknown>[] { - const first = rows[0]; - if (!first) return []; - const out: Record<string, unknown>[] = []; - for (const row of first.values) { - const mapped: Record<string, unknown> = {}; - for (let index = 0; index < first.columns.length; index += 1) { - mapped[first.columns[index] ?? String(index)] = row[index]; - } - out.push(mapped); - } - return out; -} - -let SQL: SqlJsStatic; - -beforeAll(async () => { - const require = createRequire(import.meta.url); - const wasmPath = require.resolve("sql.js/dist/sql-wasm.wasm"); - const wasmDir = path.dirname(wasmPath); - SQL = await initSqlJs({ - locateFile: (file) => path.join(wasmDir, file), - }); -}); - -function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { - const raw = new SQL.Database(); - raw.run(` - create table missions( - id text primary key, - project_id text not null, - lane_id text, - metadata_json text, - updated_at text - ); - create table orchestrator_chat_threads( - id text primary key, - project_id text not null, - mission_id text not null, - thread_type text not null, - title text not null, - run_id text, - step_id text, - step_key text, - attempt_id text, - session_id text, - lane_id text, - status text not null, - unread_count integer not null default 0, - metadata_json text, - created_at text not null, - updated_at text not null - ); - create table orchestrator_chat_messages( - id text primary key, - project_id text not null, - mission_id text not null, - thread_id text, - role text not null, - content text not null, - timestamp text not null, - step_key text, - target_json text, - visibility text, - delivery_state text, - source_session_id text, - attempt_id text, - lane_id text, - run_id text, - metadata_json text, - created_at text not null - ); - `); - - const run = (sql: string, params: SqlValue[] = []) => raw.run(sql, params); - const all = <T extends Record<string, unknown> = Record<string, unknown>>(sql: string, params: SqlValue[] = []): T[] => - mapExecRows(raw.exec(sql, params)) as T[]; - const get = <T extends Record<string, unknown> = Record<string, unknown>>(sql: string, params: SqlValue[] = []): T | null => - all<T>(sql, params)[0] ?? null; - - return { raw, db: { run, all, get } }; -} - -describe("workerDeliveryService routeMessageToCoordinatorCtx", () => { - it("answers worker status questions locally without waking the coordinator", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T17:00:30.000Z")); - try { - const appended: Array<{ content: string }> = []; - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - aiIntegrationService: {}, - projectRoot: "/tmp/project", - chatMessages: new Map(), - sessionRuntimeSignals: new Map([ - [ - "session-1", - { - laneId: "lane-1", - sessionId: "session-1", - runtimeState: "running", - lastOutputPreview: "Reviewing App.tsx and wiring the new tab.", - at: "2026-03-06T17:00:25.000Z", - }, - ], - ]), - orchestratorService: { - listRuns: vi.fn(() => [ - { - id: "run-1", - status: "active", - createdAt: "2026-03-06T17:00:00.000Z", - }, - ]), - getRunGraph: vi.fn(() => ({ - run: { - id: "run-1", - status: "active", - metadata: {}, - }, - steps: [ - { - id: "step-1", - stepKey: "implement-test-tab", - title: "Implement test tab", - status: "running", - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "running", - createdAt: "2026-03-06T17:00:10.000Z", - startedAt: "2026-03-06T17:00:10.000Z", - executorSessionId: "session-1", - resultEnvelope: null, - }, - ], - claims: [], - })), - }, - } as any; - const deps = { - appendChatMessage: vi.fn((message) => { - appended.push(message); - return message; - }), - steerMission: vi.fn(), - enqueueChatResponse: vi.fn(), - runHealthSweep: vi.fn().mockResolvedValue({ sweeps: 1, staleRecovered: 0 }), - }; - - routeMessageToCoordinatorCtx( - ctx, - { - missionId: "mission-1", - content: "What is implement-test-tab doing?", - threadId: "thread-1", - target: { kind: "coordinator", runId: "run-1" }, - } as any, - deps as any, - ); - - await vi.advanceTimersByTimeAsync(0); - - expect(deps.runHealthSweep).toHaveBeenCalledWith("chat_status"); - expect(deps.steerMission).not.toHaveBeenCalled(); - expect(deps.enqueueChatResponse).not.toHaveBeenCalled(); - expect(appended).toHaveLength(1); - expect(appended[0]?.content).toContain("Implement test tab"); - expect(appended[0]?.content).toContain("Runtime state: running."); - expect(appended[0]?.content).toContain("Latest signal: Reviewing App.tsx and wiring the new tab."); - } finally { - vi.useRealTimers(); - } - }); - - it("answers failure questions with intervention and failed-step context", async () => { - vi.useFakeTimers(); - try { - const appended: Array<{ content: string }> = []; - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - aiIntegrationService: {}, - projectRoot: "/tmp/project", - chatMessages: new Map(), - sessionRuntimeSignals: new Map(), - missionService: { - get: vi.fn(() => ({ - interventions: [ - { - id: "intervention-1", - status: "open", - title: "Planner output missing", - requestedAction: "Retry the planner or provide a manual plan summary.", - createdAt: "2026-03-06T17:00:00.000Z", - updatedAt: "2026-03-06T17:00:10.000Z", - metadata: { runId: "run-1" }, - }, - ], - })), - }, - orchestratorService: { - listRuns: vi.fn(() => [ - { - id: "run-1", - status: "paused", - createdAt: "2026-03-06T17:00:00.000Z", - }, - ]), - getRunGraph: vi.fn(() => ({ - run: { - id: "run-1", - status: "paused", - metadata: {}, - }, - steps: [ - { - id: "step-1", - stepKey: "plan-work", - title: "Plan work", - status: "failed", - createdAt: "2026-03-06T17:00:05.000Z", - updatedAt: "2026-03-06T17:00:10.000Z", - completedAt: "2026-03-06T17:00:10.000Z", - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-1", - status: "failed", - createdAt: "2026-03-06T17:00:05.000Z", - startedAt: "2026-03-06T17:00:05.000Z", - completedAt: "2026-03-06T17:00:10.000Z", - errorMessage: "Planner never wrote report_result.plan.markdown.", - }, - ], - claims: [], - })), - }, - } as any; - const deps = { - appendChatMessage: vi.fn((message) => { - appended.push(message); - return message; - }), - steerMission: vi.fn(), - enqueueChatResponse: vi.fn(), - runHealthSweep: vi.fn().mockResolvedValue({ sweeps: 0, staleRecovered: 0 }), - }; - - routeMessageToCoordinatorCtx( - ctx, - { - missionId: "mission-1", - content: "What went wrong?", - threadId: "thread-1", - target: { kind: "coordinator", runId: "run-1" }, - } as any, - deps as any, - ); - - await vi.advanceTimersByTimeAsync(0); - - expect(deps.runHealthSweep).toHaveBeenCalledWith("chat_status"); - expect(appended).toHaveLength(1); - expect(appended[0]?.content).toContain("Open intervention: Planner output missing."); - expect(appended[0]?.content).toContain("Next action: Retry the planner or provide a manual plan summary."); - } finally { - vi.useRealTimers(); - } - }); - - it("treats imperative worker guidance as a directive instead of a passive status query", () => { - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - aiIntegrationService: {}, - projectRoot: "/tmp/project", - chatMessages: new Map(), - sessionRuntimeSignals: new Map(), - orchestratorService: { - listRuns: vi.fn(() => []), - getRunGraph: vi.fn(), - }, - } as any; - const deps = { - appendChatMessage: vi.fn((message) => message), - steerMission: vi.fn(), - enqueueChatResponse: vi.fn(), - runHealthSweep: vi.fn(), - }; - - routeMessageToCoordinatorCtx( - ctx, - { - missionId: "mission-1", - content: "Tell the worker to retry with a smaller diff and report back.", - threadId: "thread-1", - target: { kind: "coordinator", runId: "run-1" }, - } as any, - deps as any, - ); - - expect(deps.runHealthSweep).not.toHaveBeenCalled(); - expect(deps.steerMission).toHaveBeenCalledTimes(1); - expect(deps.enqueueChatResponse).toHaveBeenCalledTimes(1); - }); - - it("treats plain coordinator chat as conversation instead of steering", () => { - const ctx = { - disposed: { current: false }, - logger: { debug: vi.fn() }, - aiIntegrationService: {}, - projectRoot: "/tmp/project", - chatMessages: new Map(), - sessionRuntimeSignals: new Map(), - orchestratorService: { - listRuns: vi.fn(() => []), - getRunGraph: vi.fn(), - }, - } as any; - const deps = { - appendChatMessage: vi.fn((message) => message), - steerMission: vi.fn(), - enqueueChatResponse: vi.fn(), - runHealthSweep: vi.fn(), - }; - - routeMessageToCoordinatorCtx( - ctx, - { - missionId: "mission-1", - content: "hello there", - threadId: "thread-1", - target: { kind: "coordinator", runId: "run-1" }, - } as any, - deps as any, - ); - - expect(deps.steerMission).not.toHaveBeenCalled(); - expect(deps.enqueueChatResponse).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - coordinatorChatMode: "conversation", - }), - }), - expect.any(String), - ); - }); -}); - -describe("workerDeliveryService resolveWorkerDeliverySessionCtx", () => { - it("prefers the session already bound to the worker thread before lane fallback", async () => { - const ctx = { - agentChatService: { - listSessions: vi.fn().mockResolvedValue([ - { - sessionId: "session-thread-a", - laneId: "lane-1", - provider: "codex", - status: "active", - threadId: "thread-a", - }, - { - sessionId: "session-thread-b", - laneId: "lane-1", - provider: "codex", - status: "active", - threadId: "thread-b", - }, - ]), - }, - } as any; - - const resolution = await resolveWorkerDeliverySessionCtx(ctx, { - message: { - missionId: "mission-1", - laneId: "lane-1", - threadId: "thread-a", - sourceSessionId: null, - target: { kind: "worker", sessionId: null }, - } as any, - context: { - missionId: "mission-1", - threadId: "thread-a", - laneId: "lane-1", - sessionId: null, - target: { kind: "worker", sessionId: null }, - } as any, - deliveryMeta: { - agentSessionId: null, - } as any, - }); - - expect(resolution.sessionId).toBe("session-thread-a"); - expect(resolution.error).toBeNull(); - }); - - it("blocks lane fallback when thread-bound sessions exist for other worker threads", async () => { - const ctx = { - agentChatService: { - listSessions: vi.fn().mockResolvedValue([ - { - sessionId: "session-thread-b", - laneId: "lane-1", - provider: "codex", - status: "active", - threadId: "thread-b", - }, - ]), - }, - } as any; - - const resolution = await resolveWorkerDeliverySessionCtx(ctx, { - message: { - missionId: "mission-1", - laneId: "lane-1", - threadId: "thread-a", - sourceSessionId: null, - target: { kind: "worker", sessionId: null }, - } as any, - context: { - missionId: "mission-1", - threadId: "thread-a", - laneId: "lane-1", - sessionId: null, - target: { kind: "worker", sessionId: null }, - } as any, - deliveryMeta: { - agentSessionId: null, - } as any, - }); - - expect(resolution.sessionId).toBeNull(); - expect(resolution.error).toContain("queued on the worker thread"); - }); -}); - -describe("workerDeliveryService upsertWorkerDeliveryInterventionCtx", () => { - it("does not open a manual-input intervention for a worker that has already finished", () => { - const addIntervention = vi.fn(); - const ctx = { - workerDeliveryInterventionCooldowns: new Map(), - missionService: { - get: vi.fn(() => ({ - id: "mission-1", - status: "in_progress", - interventions: [], - })), - update: vi.fn(), - addIntervention, - }, - orchestratorService: { - getRunGraph: vi.fn(() => ({ - run: { id: "run-1", missionId: "mission-1" }, - steps: [ - { - id: "step-1", - status: "succeeded", - }, - ], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - })), - }, - logger: { - info: vi.fn(), - debug: vi.fn(), - }, - } as any; - const deps = { - appendChatMessage: vi.fn(), - recordRuntimeEvent: vi.fn(), - }; - - const result = upsertWorkerDeliveryInterventionCtx( - ctx, - { - message: { - id: "message-1", - missionId: "mission-1", - laneId: "lane-1", - } as any, - context: { - runId: "run-1", - stepId: "step-1", - stepKey: "planner-step", - laneId: "lane-1", - } as any, - retries: 4, - error: "No worker agent-chat session is currently mapped to this thread.", - }, - deps as any, - ); - - expect(result).toBeNull(); - expect(addIntervention).not.toHaveBeenCalled(); - }); -}); - -describe("workerDeliveryService sendWorkerMessageToSessionWithStatusCtx", () => { - it("uses Codex steer before send so coordinator reminders do not interrupt active turns", async () => { - const sendMessage = vi.fn(); - const steer = vi.fn().mockResolvedValue({ steerId: "steer-1", queued: false }); - const listSessions = vi.fn().mockResolvedValue([ - { sessionId: "codex-session-1", provider: "codex" }, - ]); - - const result = await sendWorkerMessageToSessionWithStatusCtx( - { - agentChatService: { - listSessions, - sendMessage, - steer, - }, - } as any, - "codex-session-1", - "Coordinator reminder: verify the result lane before reporting success.", - ); - - expect(result).toEqual({ ok: true, delivered: true, method: "steer" }); - expect(steer).toHaveBeenCalledWith({ - sessionId: "codex-session-1", - text: "Coordinator reminder: verify the result lane before reporting success.", - }); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("falls back to send when a Codex worker has no active turn to steer", async () => { - const sendMessage = vi.fn().mockResolvedValue(undefined); - const steer = vi.fn().mockRejectedValue(new Error("No active turn to steer.")); - const listSessions = vi.fn().mockResolvedValue([ - { sessionId: "codex-session-2", provider: "codex" }, - ]); - - const result = await sendWorkerMessageToSessionWithStatusCtx( - { - agentChatService: { - listSessions, - sendMessage, - steer, - }, - } as any, - "codex-session-2", - "Coordinator reminder: continue when ready.", - ); - - expect(result).toEqual({ ok: true, delivered: true, method: "send" }); - expect(steer).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith({ - sessionId: "codex-session-2", - text: "Coordinator reminder: continue when ready.", - }); - }); -}); - -describe("chatMessageService sendThreadMessageCtx", () => { - function createRoutingTestHarness() { - const { db } = createInMemoryAdeDb(); - db.run( - `insert into missions(id, project_id, lane_id, metadata_json, updated_at) values (?, ?, ?, ?, ?)`, - ["mission-1", "project-1", "lane-1", "{}", "2026-03-08T12:00:00.000Z"], - ); - const ctx = { - disposed: { current: false }, - db, - logger: { debug: vi.fn() }, - chatMessages: new Map(), - missionService: {}, - } as any; - const deps = { - routeMessageToWorker: vi.fn(), - routeMessageToCoordinator: vi.fn(), - replayQueuedWorkerMessages: vi.fn().mockResolvedValue({ delivered: 0, failed: 0, queued: 0 }), - sendWorkerMessageToSession: vi.fn().mockResolvedValue("delivered"), - createContextCheckpoint: vi.fn(), - listWorkerDigests: vi.fn().mockReturnValue([]), - }; - return { ctx, deps }; - } - - it("routes a threadId-only reply on a worker thread back to that worker with preserved metadata", () => { - const { ctx, deps } = createRoutingTestHarness(); - - const workerThread = upsertThread(ctx, { - missionId: "mission-1", - threadId: "worker-thread-1", - threadType: "worker", - title: "Implement tab", - target: { - kind: "worker", - runId: "run-1", - stepId: "step-1", - stepKey: "implement-tab", - attemptId: "attempt-1", - sessionId: "session-1", - laneId: "lane-1", - }, - }); - - const sent = sendThreadMessageCtx( - ctx, - { - missionId: "mission-1", - threadId: workerThread.id, - content: "Please retry with a narrower diff and preserve the passing tests.", - metadata: { - workerDelivery: { - sourceMessageId: "source-1", - retries: 2, - }, - customFlag: true, - }, - }, - deps as any, - ); - - expect(deps.routeMessageToWorker).toHaveBeenCalledTimes(1); - expect(deps.routeMessageToCoordinator).not.toHaveBeenCalled(); - expect(sent.target).toEqual({ - kind: "worker", - runId: "run-1", - stepId: "step-1", - stepKey: "implement-tab", - attemptId: "attempt-1", - sessionId: "session-1", - laneId: "lane-1", - }); - expect(sent.stepKey).toBe("implement-tab"); - expect(sent.attemptId).toBe("attempt-1"); - expect(sent.sourceSessionId).toBe("session-1"); - expect(sent.laneId).toBe("lane-1"); - expect(sent.runId).toBe("run-1"); - expect(sent.deliveryState).toBe("queued"); - expect(sent.metadata).toMatchObject({ - workerDelivery: { - sourceMessageId: "source-1", - retries: 2, - }, - customFlag: true, - }); - - const persisted = getChatMessageById(ctx, sent.id); - expect(persisted?.target).toEqual(sent.target); - expect(persisted?.stepKey).toBe("implement-tab"); - expect(persisted?.attemptId).toBe("attempt-1"); - expect(persisted?.sourceSessionId).toBe("session-1"); - expect(persisted?.laneId).toBe("lane-1"); - expect(persisted?.runId).toBe("run-1"); - expect(persisted?.metadata).toMatchObject({ - workerDelivery: { - sourceMessageId: "source-1", - retries: 2, - }, - customFlag: true, - }); - }); - - it("backfills missing worker target fields from thread identity when an explicit partial target is provided", () => { - const { ctx, deps } = createRoutingTestHarness(); - - const workerThread = upsertThread(ctx, { - missionId: "mission-1", - threadId: "worker-thread-2", - threadType: "worker", - title: "Review results", - target: { - kind: "worker", - runId: "run-2", - stepId: "step-2", - stepKey: "review-results", - attemptId: "attempt-2", - sessionId: "session-2", - laneId: "lane-2", - }, - }); - - const sent = sendThreadMessageCtx( - ctx, - { - missionId: "mission-1", - threadId: workerThread.id, - content: "Use the current session but keep the same target step.", - target: { - kind: "worker", - runId: "run-2", - sessionId: "session-override", - }, - }, - deps as any, - ); - - expect(deps.routeMessageToWorker).toHaveBeenCalledTimes(1); - expect(sent.target).toEqual({ - kind: "worker", - runId: "run-2", - stepId: "step-2", - stepKey: "review-results", - attemptId: "attempt-2", - sessionId: "session-override", - laneId: "lane-2", - }); - expect(sent.stepKey).toBe("review-results"); - expect(sent.attemptId).toBe("attempt-2"); - expect(sent.laneId).toBe("lane-2"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts b/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts deleted file mode 100644 index b04ab734b..000000000 --- a/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts +++ /dev/null @@ -1,1675 +0,0 @@ -/** - * workerDeliveryService.ts - * - * Worker message delivery: readWorkerDeliveryMetadata, resolveWorkerDeliveryContext, - * deliverWorkerMessage, replayQueuedWorkerMessages, routeMessageToCoordinator, - * routeMessageToWorker, and supporting delivery pipeline functions. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import type { - OrchestratorContext, - WorkerDeliveryContext, - WorkerDeliverySessionResolution, - AgentChatSessionSummaryEntry, -} from "./orchestratorContext"; -import { - nowIso, - isRecord, - toOptionalString, - clipTextForContext, - missionThreadId, - parseWorkerProviderHint, - TERMINAL_STEP_STATUSES, - WORKER_MESSAGE_RETRY_BUDGET, - WORKER_MESSAGE_RETRY_INTERVENTION_COOLDOWN_MS, - WORKER_MESSAGE_INFLIGHT_LEASE_MS, - WORKER_MESSAGE_INFLIGHT_STALE_FAIL_MS, - DEFAULT_CHAT_VISIBILITY, - DEFAULT_WORKER_CHAT_VISIBILITY, -} from "./orchestratorContext"; -import { - emitThreadEvent, - getThreadById, - updateChatMessage, - getChatMessageById, - parseChatMessageRow, - emitOrchestratorMessage, - formatRecentChatContext, - summarizeRunForChat, -} from "./chatMessageService"; -import type { ChatMessageRow } from "./chatMessageService"; -import { - normalizeDeliveryError, - isBusyDeliveryError, - isNoActiveTurnError, - computeWorkerRetryBackoffMs, -} from "./chatMessageService"; -import { - getTrackedSessionState, -} from "./recoveryService"; -import { getErrorMessage } from "../shared/utils"; -import type { - OrchestratorChatMessage, - OrchestratorChatTarget, - OrchestratorChatDeliveryState, - OrchestratorExecutorKind, - SendOrchestratorChatArgs, -} from "../../../shared/types"; - -// ── Deps types for functions that need access to unextracted closure functions ── - -export type WorkerDeliveryDeps = { - appendChatMessage: (message: OrchestratorChatMessage) => OrchestratorChatMessage; - recordRuntimeEvent: (...args: any[]) => void; -}; - -export type RouteToCoordinatorDeps = WorkerDeliveryDeps & { - steerMission: (...args: any[]) => any; - enqueueChatResponse: (chatArgs: SendOrchestratorChatArgs, recentChatContext: string) => void; - runHealthSweep: (reason: string) => Promise<{ sweeps: number; staleRecovered: number }>; -}; - -export type WorkerSessionDeliveryStatus = - | { ok: true; delivered: true; method: "send" | "steer" } - | { ok: true; delivered: false; method: "steer"; reason: "worker_busy_steered" } - | { ok: false; delivered: false; reason: "no_active_session" | "delivery_failed"; error?: string }; - -function isCoordinatorStatusQuery(content: string): boolean { - const normalized = content.trim().toLowerCase(); - if (!normalized.length) return false; - - if (/\b(what went wrong|why did it fail|why is it failing|what is wrong|what failed|why did the mission fail|why is the mission blocked)\b/i.test(normalized)) { - return true; - } - - if (/^(status|progress|heartbeat|what'?s happening|what is happening)$/i.test(normalized)) { - return true; - } - - const statusTerms = /\b(status|progress|stuck|heartbeat|doing|working on|worker|agent|lane|phase|running|wrong|fail(?:ed|ing)?|error|blocked|intervention)\b/i; - if (!statusTerms.test(normalized)) return false; - - // Imperative status requests like "status update please", "give me a status update" - const imperativeStatusRequest = - /\b(status\s+update|progress\s+update|status\s+report|progress\s+report|give\s+.*(status|progress|update))\b/i; - if (imperativeStatusRequest.test(normalized)) return true; - - const questionLead = - /^(what|what's|what is|how|how's|how is|where|where's|where is|which|who|when|why|are|is|do|does|did|can|could|would|will)\b/i; - const looksLikeQuestion = normalized.includes("?") || questionLead.test(normalized); - if (!looksLikeQuestion) return false; - - const directiveLead = - /^(please\s+)?(tell|ask|send|pause|stop|cancel|retry|resume|spawn|create|change|fix|update|switch|use|move|delegate|start|finish|mark|set)\b/i; - const explicitWorkerCommand = /\b(tell|ask|send)\s+(the\s+)?(worker|agent|coordinator)\b/i; - return !directiveLead.test(normalized) && !explicitWorkerCommand.test(normalized); -} - -function isExplicitCoordinatorInstruction( - content: string, - target: OrchestratorChatMessage["target"] | null | undefined, -): boolean { - const normalized = content.trim().toLowerCase(); - if (!normalized.length) return false; - if (target?.kind === "worker" || target?.kind === "teammate") return true; - if (normalized.includes("?")) return false; - if ( - /\b(accept defaults|skip question|cancel run|resume run|retry planner|switch model|send note|tell planner|tell worker|ask planner|ask worker)\b/i.test(normalized) - ) { - return true; - } - return /^(please\s+)?(?:retry|resume|cancel|stop|pause|restart|switch|use|tell|ask|send|apply|accept|skip|continue|proceed|delegate|spawn|open|close|mark|set|prioritize|focus)\b/i.test(normalized); -} - -function buildCoordinatorStatusReply( - ctx: OrchestratorContext, - missionId: string, - content: string, -): string { - const runs = ctx.orchestratorService.listRuns({ missionId }); - if (!runs.length) return "No run has started yet."; - const byCreatedDesc = [...runs].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); - const targetRun = byCreatedDesc.find((entry) => - entry.status === "active" || entry.status === "bootstrapping" || entry.status === "queued" || entry.status === "paused" - ) ?? byCreatedDesc[0] ?? null; - if (!targetRun) return "No run has started yet."; - - try { - const graph = ctx.orchestratorService.getRunGraph({ runId: targetRun.id, timelineLimit: 0 }); - const normalizedContent = content.trim().toLowerCase(); - const mission = typeof ctx.missionService?.get === "function" ? ctx.missionService.get(missionId) : null; - const openIntervention = (mission?.interventions ?? []) - .filter((intervention) => intervention.status === "open") - .filter((intervention) => { - const metadata = isRecord(intervention.metadata) ? intervention.metadata : null; - const interventionRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - return interventionRunId.length === 0 || interventionRunId === targetRun.id; - }) - .sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt))[0] ?? null; - const asksAboutFailure = /\b(what went wrong|why did it fail|why is it failing|what is wrong|what failed|why did the mission fail|why is the mission blocked)\b/i.test(normalizedContent); - const runningSteps = graph.steps.filter((step) => step.status === "running"); - const targetStep = - graph.steps.find((step) => { - const title = (step.title ?? "").trim().toLowerCase(); - const stepKey = (step.stepKey ?? "").trim().toLowerCase(); - return (title.length > 0 && normalizedContent.includes(title)) || (stepKey.length > 0 && normalizedContent.includes(stepKey)); - }) - ?? (runningSteps.length === 1 ? runningSteps[0] : null); - const latestFailedStep = [...graph.steps] - .filter((step) => step.status === "failed") - .sort((left, right) => { - const leftTs = Date.parse(left.completedAt ?? left.updatedAt ?? left.createdAt ?? ""); - const rightTs = Date.parse(right.completedAt ?? right.updatedAt ?? right.createdAt ?? ""); - return rightTs - leftTs; - })[0] ?? null; - const latestFailedAttempt = latestFailedStep - ? graph.attempts - .filter((attempt) => attempt.stepId === latestFailedStep.id) - .sort((left, right) => { - const leftTs = Date.parse(left.completedAt ?? left.startedAt ?? left.createdAt ?? ""); - const rightTs = Date.parse(right.completedAt ?? right.startedAt ?? right.createdAt ?? ""); - return rightTs - leftTs; - })[0] ?? null - : null; - - if (!targetStep) { - const failureSummary = [ - summarizeRunForChat(ctx, missionId), - openIntervention ? `Open intervention: ${openIntervention.title}.` : null, - openIntervention?.requestedAction?.trim() - ? `Next action: ${clipTextForContext(openIntervention.requestedAction.trim(), 180)}.` - : null, - latestFailedStep - ? `Latest failed step: ${(latestFailedStep.title?.trim() || latestFailedStep.stepKey)} (${latestFailedStep.stepKey}).` - : null, - latestFailedAttempt?.errorMessage?.trim() - ? `Failure detail: ${clipTextForContext(latestFailedAttempt.errorMessage.trim(), 220)}.` - : null, - ] - .filter((entry): entry is string => Boolean(entry)) - .join(" "); - if (asksAboutFailure || openIntervention || latestFailedStep || targetRun.status === "failed" || targetRun.status === "paused") { - return failureSummary; - } - return summarizeRunForChat(ctx, missionId); - } - - const latestAttempt = graph.attempts - .filter((attempt) => attempt.stepId === targetStep.id) - .sort((left, right) => { - const leftTs = Date.parse(left.startedAt ?? left.createdAt); - const rightTs = Date.parse(right.startedAt ?? right.createdAt); - return rightTs - leftTs; - })[0] ?? null; - const sessionSignal = - latestAttempt?.executorSessionId ? ctx.sessionRuntimeSignals.get(latestAttempt.executorSessionId) ?? null : null; - const workerLabel = targetStep.title?.trim().length ? targetStep.title.trim() : targetStep.stepKey; - const startedAtMs = Date.parse(latestAttempt?.startedAt ?? latestAttempt?.createdAt ?? ""); - const elapsedSeconds = Number.isFinite(startedAtMs) - ? Math.max(0, Math.round((Date.now() - startedAtMs) / 1000)) - : null; - const latestSignal = sessionSignal?.lastOutputPreview?.trim().length - ? sessionSignal.lastOutputPreview.trim() - : latestAttempt?.resultEnvelope?.summary?.trim().length - ? latestAttempt.resultEnvelope.summary.trim() - : null; - const signalState = sessionSignal?.runtimeState ?? (latestAttempt?.status === "running" ? "running" : null); - - return [ - `${workerLabel} (${targetStep.stepKey}) is ${targetStep.status}.`, - signalState ? `Runtime state: ${signalState}.` : null, - elapsedSeconds != null ? `Elapsed: ${elapsedSeconds}s.` : null, - latestSignal ? `Latest signal: ${clipTextForContext(latestSignal, 220)}.` : null, - ] - .filter((entry): entry is string => Boolean(entry)) - .join(" "); - } catch { - return summarizeRunForChat(ctx, missionId); - } -} - -// ── readWorkerDeliveryMetadata ────────────────────────────────── - -export function readWorkerDeliveryMetadataCtx( - _ctx: OrchestratorContext, - message: OrchestratorChatMessage -): { - metadata: Record<string, unknown>; - workerDelivery: Record<string, unknown>; - retries: number; - maxRetries: number; - nextRetryAtMs: number | null; - interventionId: string | null; - agentSessionId: string | null; - inFlightAttemptId: string | null; - inFlightAtMs: number | null; - inFlightSessionId: string | null; -} { - const metadata = isRecord(message.metadata) ? { ...message.metadata } : {}; - const workerDelivery = isRecord(metadata.workerDelivery) ? { ...(metadata.workerDelivery as Record<string, unknown>) } : {}; - const retries = Number.isFinite(Number(workerDelivery.retries)) - ? Math.max(0, Math.floor(Number(workerDelivery.retries))) - : 0; - const maxRetries = Number.isFinite(Number(workerDelivery.maxRetries)) - ? Math.max(1, Math.floor(Number(workerDelivery.maxRetries))) - : WORKER_MESSAGE_RETRY_BUDGET; - const nextRetryAtRaw = typeof workerDelivery.nextRetryAt === "string" ? workerDelivery.nextRetryAt : ""; - const parsedNextRetryAt = nextRetryAtRaw ? Date.parse(nextRetryAtRaw) : Number.NaN; - const interventionId = typeof workerDelivery.interventionId === "string" && workerDelivery.interventionId.trim().length > 0 - ? workerDelivery.interventionId.trim() - : null; - const agentSessionId = typeof workerDelivery.agentSessionId === "string" && workerDelivery.agentSessionId.trim().length > 0 - ? workerDelivery.agentSessionId.trim() - : null; - const inFlightAttemptId = typeof workerDelivery.inFlightAttemptId === "string" && workerDelivery.inFlightAttemptId.trim().length > 0 - ? workerDelivery.inFlightAttemptId.trim() - : null; - const inFlightAtRaw = typeof workerDelivery.inFlightAt === "string" ? workerDelivery.inFlightAt : ""; - const parsedInFlightAt = inFlightAtRaw ? Date.parse(inFlightAtRaw) : Number.NaN; - const inFlightSessionId = typeof workerDelivery.inFlightSessionId === "string" && workerDelivery.inFlightSessionId.trim().length > 0 - ? workerDelivery.inFlightSessionId.trim() - : null; - return { - metadata, - workerDelivery, - retries, - maxRetries, - nextRetryAtMs: Number.isFinite(parsedNextRetryAt) ? parsedNextRetryAt : null, - interventionId, - agentSessionId, - inFlightAttemptId, - inFlightAtMs: Number.isFinite(parsedInFlightAt) ? parsedInFlightAt : null, - inFlightSessionId - }; -} - -// ── selectAttemptDeliveryContext ───────────────────────────────── - -export function selectAttemptDeliveryContextCtx( - ctx: OrchestratorContext, - whereClause: string, - params: Array<string | null> -): { - attempt_id: string; - run_id: string; - step_id: string; - step_key: string | null; - lane_id: string | null; - session_id: string | null; - session_status: string | null; - session_tool_type: string | null; - executor_kind: string | null; -} | null { - return ctx.db.get<{ - attempt_id: string; - run_id: string; - step_id: string; - step_key: string | null; - lane_id: string | null; - session_id: string | null; - session_status: string | null; - session_tool_type: string | null; - executor_kind: string | null; - }>( - ` - select - a.id as attempt_id, - a.run_id as run_id, - a.step_id as step_id, - s.step_key as step_key, - s.lane_id as lane_id, - a.executor_session_id as session_id, - ts.status as session_status, - ts.tool_type as session_tool_type, - a.executor_kind as executor_kind - from orchestrator_attempts a - left join orchestrator_steps s on s.id = a.step_id - left join terminal_sessions ts on ts.id = a.executor_session_id - where ${whereClause} - order by - case a.status - when 'running' then 0 - when 'queued' then 1 - when 'blocked' then 2 - else 3 - end, - a.attempt_number desc, - a.created_at desc - limit 1 - `, - params - ); -} - -// ── resolveWorkerDeliveryContext ───────────────────────────────── - -export function resolveWorkerDeliveryContextCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage -): WorkerDeliveryContext | null { - const thread = message.threadId ? getThreadById(ctx, message.missionId, message.threadId) : null; - const baseTarget = message.target?.kind === "worker" - ? message.target - : thread?.threadType === "worker" - ? { - kind: "worker" as const, - runId: thread.runId ?? null, - stepId: thread.stepId ?? null, - stepKey: thread.stepKey ?? null, - attemptId: thread.attemptId ?? null, - sessionId: thread.sessionId ?? null, - laneId: thread.laneId ?? null - } - : null; - if (!baseTarget) return null; - - const attemptCandidates = [ - toOptionalString(baseTarget.attemptId), - toOptionalString(message.attemptId), - toOptionalString(thread?.attemptId) - ].filter((value): value is string => !!value); - let attemptContext: ReturnType<typeof selectAttemptDeliveryContextCtx> = null; - for (const attemptId of attemptCandidates) { - attemptContext = selectAttemptDeliveryContextCtx(ctx, `a.id = ?`, [attemptId]); - if (attemptContext) break; - } - - const sessionCandidates = [ - toOptionalString(baseTarget.sessionId), - toOptionalString(message.sourceSessionId), - toOptionalString(thread?.sessionId) - ].filter((value): value is string => !!value); - if (!attemptContext) { - for (const sessionId of sessionCandidates) { - attemptContext = selectAttemptDeliveryContextCtx(ctx, `a.executor_session_id = ?`, [sessionId]); - if (attemptContext) break; - } - } - if (!attemptContext) { - const runId = toOptionalString(baseTarget.runId) ?? toOptionalString(thread?.runId) ?? toOptionalString(message.runId); - const stepId = toOptionalString(baseTarget.stepId) ?? toOptionalString(thread?.stepId); - if (runId && stepId) { - attemptContext = selectAttemptDeliveryContextCtx(ctx, `a.run_id = ? and a.step_id = ?`, [runId, stepId]); - } - if (!attemptContext && runId) { - const stepKey = toOptionalString(baseTarget.stepKey) ?? toOptionalString(thread?.stepKey) ?? toOptionalString(message.stepKey); - if (stepKey) { - attemptContext = selectAttemptDeliveryContextCtx(ctx, `a.run_id = ? and s.step_key = ?`, [runId, stepKey]); - } - } - } - const runId = - toOptionalString(baseTarget.runId) - ?? toOptionalString(thread?.runId) - ?? toOptionalString(message.runId) - ?? toOptionalString(attemptContext?.run_id) - ?? null; - const stepId = - toOptionalString(baseTarget.stepId) - ?? toOptionalString(thread?.stepId) - ?? toOptionalString(attemptContext?.step_id) - ?? null; - const stepKey = - toOptionalString(baseTarget.stepKey) - ?? toOptionalString(thread?.stepKey) - ?? toOptionalString(message.stepKey) - ?? toOptionalString(attemptContext?.step_key) - ?? null; - const attemptId = - toOptionalString(baseTarget.attemptId) - ?? toOptionalString(message.attemptId) - ?? toOptionalString(thread?.attemptId) - ?? toOptionalString(attemptContext?.attempt_id) - ?? null; - const laneId = - toOptionalString(baseTarget.laneId) - ?? toOptionalString(thread?.laneId) - ?? toOptionalString(message.laneId) - ?? toOptionalString(attemptContext?.lane_id) - ?? null; - const sessionId = - toOptionalString(baseTarget.sessionId) - ?? toOptionalString(message.sourceSessionId) - ?? toOptionalString(thread?.sessionId) - ?? toOptionalString(attemptContext?.session_id) - ?? null; - const sessionStatusFromDb = toOptionalString(attemptContext?.session_status)?.toLowerCase() ?? null; - const trackedSessionStatus = sessionId - ? toOptionalString(getTrackedSessionState(ctx, sessionId)?.status)?.toLowerCase() ?? null - : null; - const sessionStatus = sessionStatusFromDb ?? trackedSessionStatus; - const sessionToolType = toOptionalString(attemptContext?.session_tool_type) ?? null; - const executorKindRaw = toOptionalString(attemptContext?.executor_kind); - const executorKind: OrchestratorExecutorKind | null = - executorKindRaw === "claude" - || executorKindRaw === "codex" - || executorKindRaw === "cursor" - || executorKindRaw === "droid" - || executorKindRaw === "opencode" - || executorKindRaw === "shell" - || executorKindRaw === "manual" - ? executorKindRaw - : null; - const resolvedTarget: Extract<OrchestratorChatTarget, { kind: "worker" }> = { - kind: "worker", - runId, - stepId, - stepKey, - attemptId, - sessionId, - laneId - }; - return { - missionId: message.missionId, - threadId: message.threadId ?? missionThreadId(message.missionId), - target: resolvedTarget, - runId, - stepId, - stepKey, - attemptId, - laneId, - sessionId, - sessionStatus, - sessionToolType, - executorKind - }; -} - -// ── persistThreadWorkerLinks ──────────────────────────────────── - -export function persistThreadWorkerLinksCtx( - ctx: OrchestratorContext, - context: WorkerDeliveryContext -): void { - ctx.db.run( - ` - update orchestrator_chat_threads - set - run_id = coalesce(?, run_id), - step_id = coalesce(?, step_id), - step_key = coalesce(?, step_key), - attempt_id = coalesce(?, attempt_id), - session_id = coalesce(?, session_id), - lane_id = coalesce(?, lane_id), - updated_at = ? - where mission_id = ? - and id = ? - `, - [ - context.runId, - context.stepId, - context.stepKey, - context.attemptId, - context.sessionId, - context.laneId, - nowIso(), - context.missionId, - context.threadId - ] - ); -} - -// ── upsertWorkerDeliveryIntervention ──────────────────────────── - -export function upsertWorkerDeliveryInterventionCtx( - ctx: OrchestratorContext, - args: { - message: OrchestratorChatMessage; - context: WorkerDeliveryContext | null; - retries: number; - error: string; - }, - deps: WorkerDeliveryDeps -): string | null { - const cooldownKey = args.message.id; - const nowMs = Date.now(); - const lastMs = ctx.workerDeliveryInterventionCooldowns.get(cooldownKey) ?? 0; - if (nowMs - lastMs < WORKER_MESSAGE_RETRY_INTERVENTION_COOLDOWN_MS) { - return null; - } - const mission = ctx.missionService.get(args.message.missionId); - if (!mission) return null; - if (args.context?.runId && args.context.stepId) { - try { - const graph = ctx.orchestratorService.getRunGraph({ runId: args.context.runId, timelineLimit: 0 }); - const step = graph.steps.find((entry) => entry.id === args.context?.stepId) ?? null; - if (step && TERMINAL_STEP_STATUSES.has(step.status)) { - ctx.logger.info("ai_orchestrator.worker_delivery_intervention_suppressed_terminal_step", { - missionId: args.message.missionId, - runId: args.context.runId, - stepId: args.context.stepId, - stepStatus: step.status, - messageId: args.message.id, - retries: args.retries, - }); - return null; - } - } catch { - // Best effort only. If graph lookup fails, keep the existing recovery path. - } - } - if (mission.status === "queued") { - try { - ctx.missionService.update({ - missionId: args.message.missionId, - status: "in_progress" - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.worker_delivery_promote_mission_failed", { - missionId: args.message.missionId, - error: error instanceof Error ? error.message : String(error) - }); - } - } - const existing = mission.interventions.find((entry) => { - if (entry.status !== "open") return false; - if (!isRecord(entry.metadata)) return false; - return entry.metadata.sourceMessageId === args.message.id; - }); - if (existing) { - ctx.workerDeliveryInterventionCooldowns.set(cooldownKey, nowMs); - return existing.id; - } - - const workerLabel = - toOptionalString(args.context?.stepKey) - ?? toOptionalString(args.context?.attemptId) - ?? toOptionalString(args.context?.sessionId) - ?? "worker"; - const intervention = ctx.missionService.addIntervention({ - missionId: args.message.missionId, - interventionType: "manual_input", - title: `Worker delivery blocked: ${workerLabel}`, - body: `Could not deliver operator guidance to ${workerLabel} after ${args.retries} retries. Latest error: ${args.error}`, - requestedAction: "Open the worker thread and resend guidance once the worker session is available.", - laneId: args.context?.laneId ?? args.message.laneId ?? null, - pauseMission: false, - metadata: { - sourceMessageId: args.message.id, - threadId: args.message.threadId ?? null, - attemptId: args.context?.attemptId ?? args.message.attemptId ?? null, - sessionId: args.context?.sessionId ?? args.message.sourceSessionId ?? null, - runId: args.context?.runId ?? args.message.runId ?? null, - workerDeliveryFailure: true, - recoveryType: "worker_delivery_failure", - } - }); - ctx.workerDeliveryInterventionCooldowns.set(cooldownKey, nowMs); - if (args.context?.runId) { - deps.recordRuntimeEvent({ - runId: args.context.runId, - stepId: args.context.stepId, - attemptId: args.context.attemptId, - sessionId: args.context.sessionId, - eventType: "intervention_opened", - eventKey: `worker_delivery_intervention:${intervention.id}`, - payload: { - interventionId: intervention.id, - sourceMessageId: args.message.id, - retries: args.retries, - error: args.error - } - }); - } - emitOrchestratorMessage( - ctx, - args.message.missionId, - `Worker guidance could not be delivered after ${args.retries} retries. I opened intervention "${intervention.title}" so you can recover it deterministically.`, - args.context?.stepKey ?? args.message.stepKey ?? null, - { - sourceMessageId: args.message.id, - interventionId: intervention.id, - retries: args.retries - }, - { appendChatMessage: deps.appendChatMessage } - ); - return intervention.id; -} - -// ── updateWorkerDeliveryState ─────────────────────────────────── - -export function updateWorkerDeliveryStateCtx( - ctx: OrchestratorContext, - args: { - message: OrchestratorChatMessage; - context: WorkerDeliveryContext | null; - state: OrchestratorChatDeliveryState; - retries: number; - maxRetries: number; - error: string | null; - method: "send" | "steer" | "queued" | "failed"; - nextRetryAt: string | null; - interventionId?: string | null; - deliverySessionId?: string | null; - } -): OrchestratorChatMessage { - const updated = updateChatMessage(ctx, args.message.id, (current) => { - const deliveryMeta = readWorkerDeliveryMetadataCtx(ctx, current); - const metadata = deliveryMeta.metadata; - metadata.workerDelivery = { - ...(deliveryMeta.workerDelivery ?? {}), - retries: args.retries, - maxRetries: args.maxRetries, - lastAttemptAt: nowIso(), - lastMethod: args.method, - lastError: args.error, - nextRetryAt: args.nextRetryAt, - deliveredAt: args.state === "delivered" ? nowIso() : null, - interventionId: args.interventionId ?? deliveryMeta.interventionId ?? null, - agentSessionId: args.deliverySessionId ?? deliveryMeta.agentSessionId ?? null, - inFlightAttemptId: null, - inFlightAt: null, - inFlightSessionId: null - }; - const contextTarget = args.context?.target ?? (current.target?.kind === "worker" ? current.target : null); - const target = - contextTarget && contextTarget.kind === "worker" - ? { - ...contextTarget, - sessionId: args.deliverySessionId ?? contextTarget.sessionId ?? null - } - : contextTarget; - return { - ...current, - target, - deliveryState: args.state, - sourceSessionId: args.deliverySessionId ?? args.context?.sessionId ?? current.sourceSessionId ?? null, - attemptId: args.context?.attemptId ?? current.attemptId ?? null, - laneId: args.context?.laneId ?? current.laneId ?? null, - runId: args.context?.runId ?? current.runId ?? null, - stepKey: args.context?.stepKey ?? current.stepKey ?? null, - metadata - }; - }); - return updated ?? args.message; -} - -// ── markWorkerDeliveryInFlight ────────────────────────────────── - -export function markWorkerDeliveryInFlightCtx( - ctx: OrchestratorContext, - args: { - message: OrchestratorChatMessage; - context: WorkerDeliveryContext | null; - retries: number; - maxRetries: number; - deliverySessionId: string; - } -): OrchestratorChatMessage { - const inFlightAttemptId = `${args.message.id}:attempt:${args.retries + 1}`; - const updated = updateChatMessage(ctx, args.message.id, (current) => { - const deliveryMeta = readWorkerDeliveryMetadataCtx(ctx, current); - const metadata = deliveryMeta.metadata; - metadata.workerDelivery = { - ...(deliveryMeta.workerDelivery ?? {}), - retries: args.retries, - maxRetries: args.maxRetries, - lastAttemptAt: nowIso(), - lastMethod: "attempt", - lastError: null, - nextRetryAt: null, - deliveredAt: null, - interventionId: deliveryMeta.interventionId ?? null, - agentSessionId: args.deliverySessionId, - inFlightAttemptId, - inFlightAt: nowIso(), - inFlightSessionId: args.deliverySessionId - }; - const contextTarget = args.context?.target ?? (current.target?.kind === "worker" ? current.target : null); - const target = - contextTarget && contextTarget.kind === "worker" - ? { - ...contextTarget, - sessionId: args.deliverySessionId - } - : contextTarget; - return { - ...current, - target, - deliveryState: "queued", - sourceSessionId: args.deliverySessionId, - attemptId: args.context?.attemptId ?? current.attemptId ?? null, - laneId: args.context?.laneId ?? current.laneId ?? null, - runId: args.context?.runId ?? current.runId ?? null, - stepKey: args.context?.stepKey ?? current.stepKey ?? null, - metadata - }; - }); - return updated ?? args.message; -} - -// ── failWorkerDeliveryStaleInFlight ───────────────────────────── - -export function failWorkerDeliveryStaleInFlightCtx( - ctx: OrchestratorContext, - args: { - message: OrchestratorChatMessage; - context: WorkerDeliveryContext | null; - retries: number; - maxRetries: number; - ageMs: number; - }, - deps: WorkerDeliveryDeps -): OrchestratorChatMessage { - const ageSeconds = Math.max(1, Math.floor(args.ageMs / 1_000)); - const error = `Delivery attempt remained in-flight for ${ageSeconds}s without confirmation; marking failed to avoid duplicate worker injection.`; - const interventionId = upsertWorkerDeliveryInterventionCtx( - ctx, - { - message: args.message, - context: args.context, - retries: Math.max(args.retries, args.maxRetries), - error - }, - deps - ); - const failed = updateWorkerDeliveryStateCtx(ctx, { - message: args.message, - context: args.context, - state: "failed", - retries: Math.max(args.retries, args.maxRetries), - maxRetries: args.maxRetries, - error, - method: "failed", - nextRetryAt: null, - interventionId - }); - emitThreadEvent(ctx, { - type: "worker_replay", - missionId: failed.missionId, - threadId: failed.threadId ?? missionThreadId(failed.missionId), - messageId: failed.id, - runId: failed.runId ?? null, - reason: "stale_inflight_failed" - }); - return failed; -} - -// ── resolveWorkerDeliverySession ──────────────────────────────── - -export async function resolveWorkerDeliverySessionCtx( - ctx: OrchestratorContext, - args: { - message: OrchestratorChatMessage; - context: WorkerDeliveryContext | null; - deliveryMeta: ReturnType<typeof readWorkerDeliveryMetadataCtx>; - } -): Promise<WorkerDeliverySessionResolution> { - const providerHint = - parseWorkerProviderHint(args.context?.executorKind) - ?? parseWorkerProviderHint(args.context?.sessionToolType) - ?? parseWorkerProviderHint(args.message.target?.kind === "worker" ? args.message.target.sessionId : null) - ?? parseWorkerProviderHint(args.message.sourceSessionId) - ?? null; - if (!ctx.agentChatService) { - return { - sessionId: null, - source: "mapped", - providerHint, - summary: null, - error: "Agent chat service unavailable." - }; - } - const laneId = toOptionalString(args.context?.laneId) ?? toOptionalString(args.message.laneId); - const sessions = await ctx.agentChatService.listSessions(laneId ?? undefined); - const byId = new Map<string, AgentChatSessionSummaryEntry>(); - for (const summary of sessions) { - const sessionId = toOptionalString(summary.sessionId); - if (!sessionId) continue; - byId.set(sessionId, summary); - } - - const stickySessionId = toOptionalString(args.deliveryMeta.agentSessionId); - if (stickySessionId) { - const sticky = byId.get(stickySessionId); - if (sticky) { - return { - sessionId: sticky.sessionId, - source: "sticky", - providerHint: parseWorkerProviderHint(sticky.provider) ?? providerHint, - summary: sticky, - error: null - }; - } - } - - const directCandidates = [ - toOptionalString(args.context?.sessionId), - toOptionalString(args.message.sourceSessionId) - ].filter((value): value is string => !!value); - for (const candidate of directCandidates) { - const summary = byId.get(candidate); - if (!summary) continue; - return { - sessionId: summary.sessionId, - source: "mapped", - providerHint: parseWorkerProviderHint(summary.provider) ?? providerHint, - summary, - error: null - }; - } - - const threadId = toOptionalString(args.context?.threadId) ?? toOptionalString(args.message.threadId); - const sessionsWithThreadBinding = sessions.filter((entry) => !!toOptionalString(entry.threadId)); - if (threadId && sessionsWithThreadBinding.length > 0) { - const threadScoped = sessionsWithThreadBinding.filter((entry) => toOptionalString(entry.threadId) === threadId); - const providerScopedThread = providerHint - ? threadScoped.filter((entry) => entry.provider === providerHint) - : threadScoped; - const activeProviderScopedThread = providerScopedThread.filter((entry) => entry.status !== "ended"); - if (activeProviderScopedThread.length === 1) { - return { - sessionId: activeProviderScopedThread[0].sessionId, - source: "lane_fallback", - providerHint, - summary: activeProviderScopedThread[0], - error: null - }; - } - if (activeProviderScopedThread.length > 1) { - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "Multiple active worker sessions could match this worker thread, so ADE kept the message queued instead of risking delivery to the wrong worker." - }; - } - if (providerScopedThread.length === 1) { - return { - sessionId: providerScopedThread[0].sessionId, - source: "lane_fallback", - providerHint, - summary: providerScopedThread[0], - error: null - }; - } - if (providerScopedThread.length > 1) { - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "ADE found multiple past worker sessions for this thread, but none are live right now. The message will stay queued until the worker becomes messageable again." - }; - } - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "This worker does not currently have a live interactive session. ADE kept the message queued on the worker thread instead of sending it somewhere unsafe." - }; - } - - if (!laneId) { - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "This worker thread is not linked to a live lane session right now. ADE kept the message queued for the worker instead of guessing." - }; - } - - const providerScoped = providerHint - ? sessions.filter((entry) => entry.provider === providerHint) - : sessions; - const activeProviderScoped = providerScoped.filter((entry) => entry.status !== "ended"); - if (activeProviderScoped.length === 1) { - return { - sessionId: activeProviderScoped[0].sessionId, - source: "lane_fallback", - providerHint, - summary: activeProviderScoped[0], - error: null - }; - } - if (activeProviderScoped.length > 1) { - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "Multiple active worker sessions are available on this lane, so ADE kept the message queued rather than risking delivery to the wrong worker." - }; - } - if (providerScoped.length === 1) { - return { - sessionId: providerScoped[0].sessionId, - source: "lane_fallback", - providerHint, - summary: providerScoped[0], - error: null - }; - } - if (providerScoped.length > 1) { - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "Multiple past worker sessions were found on this lane, but none are live. ADE will keep the message queued until a live worker session appears." - }; - } - - return { - sessionId: null, - source: "lane_fallback", - providerHint, - summary: null, - error: "This worker is running without a live interactive chat session right now. ADE kept the message queued on the worker thread so the worker can still pick it up." - }; -} - -// ── sendWorkerMessageToSession ────────────────────────────────── - -function inferSessionDeliveryFailureReason(errorText: string): "no_active_session" | "delivery_failed" { - const lower = errorText.toLowerCase(); - if ( - lower.includes("no active turn") - || lower.includes("session not found") - || lower.includes("does not have a live runtime") - || lower.includes("no worker session") - ) { - return "no_active_session"; - } - return "delivery_failed"; -} - -async function isSteerLikelyQueuedForSession( - ctx: OrchestratorContext, - sessionId: string -): Promise<boolean> { - if (!ctx.agentChatService) return true; - try { - const sessions = await ctx.agentChatService.listSessions(); - const session = sessions.find((entry) => entry.sessionId === sessionId) ?? null; - if (!session) return true; - const provider = typeof session.provider === "string" ? session.provider.trim().toLowerCase() : ""; - // Only known queued providers should stay queued. Unknown providers that accept steer - // should count as delivered so we do not immediately retry with another direct send. - if (provider === "claude" || provider === "opencode") { - return true; - } - return false; - } catch { - // Conservative default: assume steer is queued if we cannot resolve provider. - return true; - } -} - -async function shouldPreferSteerForWorkerSession( - ctx: OrchestratorContext, - sessionId: string -): Promise<boolean> { - if (!ctx.agentChatService) return false; - try { - const sessions = await ctx.agentChatService.listSessions(); - const session = sessions.find((entry) => entry.sessionId === sessionId) ?? null; - const provider = typeof session?.provider === "string" ? session.provider.trim().toLowerCase() : ""; - // Codex rejects a second send while a turn is active. Start with steer so - // coordinator nudges attach to the in-flight turn instead of interrupting it. - return provider === "codex"; - } catch { - return false; - } -} - -function isQueuedSteerDeliveryPending( - ctx: OrchestratorContext, - message: OrchestratorChatMessage, - sessionId: string, -): boolean { - const deliveryMeta = readWorkerDeliveryMetadataCtx(ctx, message); - const lastMethod = typeof deliveryMeta.workerDelivery.lastMethod === "string" - ? deliveryMeta.workerDelivery.lastMethod.trim() - : ""; - if (lastMethod !== "steer") return false; - const mappedSessionId = - toOptionalString(message.sourceSessionId) - ?? toOptionalString(deliveryMeta.workerDelivery.agentSessionId) - ?? toOptionalString(deliveryMeta.inFlightSessionId); - return mappedSessionId === sessionId; -} - -export async function sendWorkerMessageToSessionWithStatusCtx( - ctx: OrchestratorContext, - sessionId: string, - text: string, - options?: { priority?: "normal" | "urgent" } -): Promise<WorkerSessionDeliveryStatus> { - if (!ctx.agentChatService) { - return { - ok: false, - delivered: false, - reason: "delivery_failed", - error: "Agent chat service unavailable." - }; - } - const agentChatService = ctx.agentChatService; - const priority = options?.priority === "urgent" ? "urgent" : "normal"; - const attemptSteerDelivery = async (): Promise<WorkerSessionDeliveryStatus> => { - try { - await agentChatService.steer({ - sessionId, - text - }); - const steerQueued = await isSteerLikelyQueuedForSession(ctx, sessionId); - if (steerQueued) { - return { - ok: true, - delivered: false, - reason: "worker_busy_steered", - method: "steer" - }; - } - return { ok: true, delivered: true, method: "steer" }; - } catch (steerError) { - const steerText = normalizeDeliveryError(steerError); - if (!isNoActiveTurnError(steerText)) { - return { - ok: false, - delivered: false, - reason: inferSessionDeliveryFailureReason(steerText), - error: steerText - }; - } - try { - await agentChatService.sendMessage({ - sessionId, - text - }); - return { ok: true, delivered: true, method: "send" }; - } catch (retryError) { - const retryText = normalizeDeliveryError(retryError); - if (isBusyDeliveryError(retryText)) { - return { - ok: true, - delivered: false, - reason: "worker_busy_steered", - method: "steer" - }; - } - return { - ok: false, - delivered: false, - reason: inferSessionDeliveryFailureReason(retryText), - error: retryText - }; - } - } - }; - if (priority === "urgent") { - return await attemptSteerDelivery(); - } - if (await shouldPreferSteerForWorkerSession(ctx, sessionId)) { - return await attemptSteerDelivery(); - } - try { - await ctx.agentChatService.sendMessage({ - sessionId, - text - }); - return { ok: true, delivered: true, method: "send" }; - } catch (error) { - const sendError = normalizeDeliveryError(error); - if (!isBusyDeliveryError(sendError)) { - return { - ok: false, - delivered: false, - reason: inferSessionDeliveryFailureReason(sendError), - error: sendError - }; - } - return await attemptSteerDelivery(); - } -} - -export async function sendWorkerMessageToSessionCtx( - ctx: OrchestratorContext, - sessionId: string, - text: string, - options?: { priority?: "normal" | "urgent" } -): Promise<"send" | "steer"> { - const status = await sendWorkerMessageToSessionWithStatusCtx(ctx, sessionId, text, options); - if (!status.ok) { - throw new Error(status.error ?? status.reason); - } - return status.method; -} - -// ── deliverWorkerMessage ──────────────────────────────────────── - -export async function deliverWorkerMessageCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage, - deps: WorkerDeliveryDeps, - options?: { ignoreBackoff?: boolean } -): Promise<OrchestratorChatMessage> { - if (ctx.disposed.current) return message; - const contextBase = resolveWorkerDeliveryContextCtx(ctx, message); - if (contextBase) { - persistThreadWorkerLinksCtx(ctx, contextBase); - } - const deliveryMeta = readWorkerDeliveryMetadataCtx(ctx, message); - if (!options?.ignoreBackoff && deliveryMeta.nextRetryAtMs != null && Date.now() < deliveryMeta.nextRetryAtMs) { - return message; - } - if (deliveryMeta.inFlightAttemptId && deliveryMeta.inFlightAtMs != null) { - const inFlightAgeMs = Date.now() - deliveryMeta.inFlightAtMs; - if (inFlightAgeMs < WORKER_MESSAGE_INFLIGHT_LEASE_MS) { - return message; - } - if (inFlightAgeMs >= WORKER_MESSAGE_INFLIGHT_STALE_FAIL_MS) { - return failWorkerDeliveryStaleInFlightCtx( - ctx, - { - message, - context: contextBase, - retries: Math.max(deliveryMeta.retries + 1, deliveryMeta.maxRetries), - maxRetries: deliveryMeta.maxRetries, - ageMs: inFlightAgeMs - }, - deps - ); - } - } - - if (!ctx.agentChatService) { - ctx.logger.warn("ai_orchestrator.worker_delivery_no_chat_service", { - messageId: message.id, - missionId: message.missionId, - threadId: message.threadId ?? null - }); - return updateWorkerDeliveryStateCtx(ctx, { - message, - context: contextBase, - state: "queued", - retries: deliveryMeta.retries, - maxRetries: deliveryMeta.maxRetries, - error: "Agent chat service is unavailable.", - method: "queued", - nextRetryAt: null - }); - } - - let context: WorkerDeliveryContext | null = contextBase; - let workingMessage = message; - try { - const sessionResolution = await resolveWorkerDeliverySessionCtx(ctx, { - message, - context: contextBase, - deliveryMeta - }); - const resolvedSessionId = toOptionalString(sessionResolution.sessionId); - context = - contextBase && resolvedSessionId - ? { - ...contextBase, - sessionId: resolvedSessionId, - target: { - ...contextBase.target, - sessionId: resolvedSessionId - } - } - : contextBase; - - if (contextBase && resolvedSessionId && contextBase.sessionId !== resolvedSessionId && context) { - persistThreadWorkerLinksCtx(ctx, context); - } - if (resolvedSessionId && sessionResolution.summary?.status === "ended") { - try { - await ctx.agentChatService.resumeSession({ sessionId: resolvedSessionId }); - } catch { - // Best-effort resume path; final delivery attempt below determines state. - } - } - - const sessionId = resolvedSessionId; - if (!sessionId) { - throw new Error(sessionResolution.error ?? "No worker session is currently mapped to this thread."); - } - workingMessage = markWorkerDeliveryInFlightCtx(ctx, { - message, - context, - retries: deliveryMeta.retries, - maxRetries: deliveryMeta.maxRetries, - deliverySessionId: sessionId - }); - const deliveryStatus = await sendWorkerMessageToSessionWithStatusCtx(ctx, sessionId, message.content); - if (!deliveryStatus.ok) { - throw new Error(deliveryStatus.error ?? deliveryStatus.reason); - } - if (!deliveryStatus.delivered) { - return updateWorkerDeliveryStateCtx(ctx, { - message: workingMessage, - context, - state: "queued", - retries: deliveryMeta.retries, - maxRetries: deliveryMeta.maxRetries, - error: "Delivery accepted into the worker session queue; awaiting the current turn to finish.", - method: deliveryStatus.method, - nextRetryAt: null, - deliverySessionId: sessionId - }); - } - const delivered = updateWorkerDeliveryStateCtx(ctx, { - message: workingMessage, - context, - state: "delivered", - retries: deliveryMeta.retries, - maxRetries: deliveryMeta.maxRetries, - error: null, - method: deliveryStatus.method, - nextRetryAt: null, - deliverySessionId: sessionId - }); - if (message.deliveryState === "queued" && delivered.deliveryState === "delivered") { - const workerLabel = - toOptionalString(context?.stepKey) - ?? toOptionalString(context?.attemptId) - ?? toOptionalString(context?.sessionId) - ?? "worker"; - emitOrchestratorMessage( - ctx, - delivered.missionId, - `Delivered queued worker guidance to ${workerLabel}.`, - context?.stepKey ?? delivered.stepKey ?? null, - { - sourceMessageId: delivered.id, - threadId: delivered.threadId ?? null, - deliveryMethod: deliveryStatus.method - }, - { appendChatMessage: deps.appendChatMessage } - ); - } - return delivered; - } catch (error) { - const failure = normalizeDeliveryError(error); - const nextRetries = deliveryMeta.retries + 1; - const exhausted = nextRetries >= deliveryMeta.maxRetries; - if (exhausted) { - const interventionId = upsertWorkerDeliveryInterventionCtx( - ctx, - { - message: workingMessage, - context, - retries: nextRetries, - error: failure - }, - deps - ); - return updateWorkerDeliveryStateCtx(ctx, { - message: workingMessage, - context, - state: "failed", - retries: nextRetries, - maxRetries: deliveryMeta.maxRetries, - error: failure, - method: "failed", - nextRetryAt: null, - interventionId - }); - } - const nextRetryAt = new Date(Date.now() + computeWorkerRetryBackoffMs(nextRetries)).toISOString(); - ctx.logger.warn("ai_orchestrator.worker_delivery_retry_queued", { - messageId: workingMessage.id, - missionId: workingMessage.missionId, - retries: nextRetries, - maxRetries: deliveryMeta.maxRetries, - error: failure, - nextRetryAt - }); - return updateWorkerDeliveryStateCtx(ctx, { - message: workingMessage, - context, - state: "queued", - retries: nextRetries, - maxRetries: deliveryMeta.maxRetries, - error: failure, - method: "queued", - nextRetryAt - }); - } -} - -// ── replayQueuedWorkerMessages ────────────────────────────────── - -export async function replayQueuedWorkerMessagesCtx( - ctx: OrchestratorContext, - args: { - reason: string; - missionId?: string | null; - threadId?: string | null; - sessionId?: string | null; - }, - deps: WorkerDeliveryDeps -): Promise<{ delivered: number; failed: number; queued: number }> { - if (ctx.disposed.current) return { delivered: 0, failed: 0, queued: 0 }; - const rows = ctx.db.all<ChatMessageRow & { thread_type: string | null; created_at: string }>( - ` - select - m.id as id, - m.mission_id as mission_id, - m.role as role, - m.content as content, - m.timestamp as timestamp, - m.step_key as step_key, - m.thread_id as thread_id, - t.thread_type as thread_type, - m.target_json as target_json, - m.visibility as visibility, - m.delivery_state as delivery_state, - m.source_session_id as source_session_id, - m.attempt_id as attempt_id, - m.lane_id as lane_id, - m.run_id as run_id, - m.metadata_json as metadata_json, - m.created_at as created_at - from orchestrator_chat_messages m - left join orchestrator_chat_threads t on t.id = m.thread_id - where m.role = 'user' - and m.delivery_state = 'queued' - and (? is null or m.mission_id = ?) - and (? is null or m.thread_id = ?) - order by m.timestamp asc, m.created_at asc, m.id asc - `, - [ - args.missionId ?? null, - args.missionId ?? null, - args.threadId ?? null, - args.threadId ?? null - ] - ); - - const grouped = new Map<string, OrchestratorChatMessage[]>(); - for (const row of rows) { - const parsed = parseChatMessageRow(row); - if (!parsed) continue; - const isWorkerThread = row.thread_type === "worker"; - if (parsed.target?.kind !== "worker" && !isWorkerThread) continue; - const key = parsed.threadId ?? missionThreadId(parsed.missionId); - const bucket = grouped.get(key) ?? []; - bucket.push(parsed); - grouped.set(key, bucket); - } - - let delivered = 0; - let failed = 0; - let queued = 0; - - const threadEntries = Array.from(grouped.entries()); - const maxParallelThreads = 8; - for (let batchIndex = 0; batchIndex < threadEntries.length; batchIndex += maxParallelThreads) { - const batch = threadEntries.slice(batchIndex, batchIndex + maxParallelThreads); - await Promise.all( - batch.map(async ([threadId, messages]) => { - const ignoreBackoff = args.reason.startsWith("runtime_signal") || args.reason.startsWith("agent_chat"); - const previous = ctx.workerDeliveryThreadQueues.get(threadId) ?? Promise.resolve(); - const next = previous - .catch((error) => { ctx.logger.warn("ai_orchestrator.worker_delivery_queue_previous_failed", { threadId, error: getErrorMessage(error) }); }) - .then(async () => { - if (ctx.disposed.current) return; - for (const candidate of messages) { - if (ctx.disposed.current) return; - const fresh = getChatMessageById(ctx, candidate.id); - if (!fresh || fresh.deliveryState !== "queued") continue; - const context = resolveWorkerDeliveryContextCtx(ctx, fresh); - if (args.sessionId) { - const signalSessionId = args.sessionId.trim(); - if (signalSessionId.length > 0) { - const mapped = toOptionalString(context?.sessionId) ?? toOptionalString(fresh.sourceSessionId); - if (!mapped || mapped !== signalSessionId) continue; - if (args.reason.startsWith("agent_chat_event") && isQueuedSteerDeliveryPending(ctx, fresh, signalSessionId)) { - updateWorkerDeliveryStateCtx(ctx, { - message: fresh, - context, - state: "delivered", - retries: readWorkerDeliveryMetadataCtx(ctx, fresh).retries, - maxRetries: readWorkerDeliveryMetadataCtx(ctx, fresh).maxRetries, - error: null, - method: "steer", - nextRetryAt: null, - deliverySessionId: signalSessionId - }); - delivered += 1; - continue; - } - } - } - const updated = await deliverWorkerMessageCtx(ctx, fresh, deps, { - ignoreBackoff - }); - if (updated.deliveryState === "delivered") { - delivered += 1; - continue; - } - if (updated.deliveryState === "failed") { - failed += 1; - continue; - } - queued += 1; - break; - } - }) - .catch((error) => { - ctx.logger.debug("ai_orchestrator.worker_delivery_replay_failed", { - reason: args.reason, - threadId, - error: error instanceof Error ? error.message : String(error) - }); - }) - .finally(() => { - if (ctx.workerDeliveryThreadQueues.get(threadId) === next) { - ctx.workerDeliveryThreadQueues.delete(threadId); - } - }); - ctx.workerDeliveryThreadQueues.set(threadId, next); - await next; - }) - ); - } - - if ((delivered > 0 || failed > 0 || queued > 0) && args.missionId) { - emitThreadEvent(ctx, { - type: "worker_replay", - missionId: args.missionId, - threadId: args.threadId ?? null, - runId: null, - reason: args.reason, - metadata: { - delivered, - failed, - queued, - sessionId: args.sessionId ?? null - } - }); - } - - return { delivered, failed, queued }; -} - -// ── routeMessageToCoordinator ─────────────────────────────────── - -export function routeMessageToCoordinatorCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage, - deps: RouteToCoordinatorDeps -): void { - const explicitInstruction = isExplicitCoordinatorInstruction(message.content, message.target); - const chatArgs: SendOrchestratorChatArgs = { - missionId: message.missionId, - content: message.content, - threadId: message.threadId ?? missionThreadId(message.missionId), - target: { - kind: "coordinator", - runId: message.runId ?? null - }, - visibilityMode: message.visibility ?? DEFAULT_CHAT_VISIBILITY, - metadata: { - ...(isRecord(message.metadata) ? message.metadata : {}), - coordinatorChatMode: explicitInstruction ? "instruction" : "conversation", - } - }; - const recentChatContext = formatRecentChatContext(ctx.chatMessages.get(message.missionId) ?? [message]); - const statusIntent = isCoordinatorStatusQuery(message.content); - if (statusIntent) { - void (async () => { - if (ctx.disposed.current) return; - try { - const sweep = await deps.runHealthSweep("chat_status"); - if (ctx.disposed.current) return; - const summary = buildCoordinatorStatusReply(ctx, message.missionId, message.content); - const recoveryNote = - sweep.staleRecovered > 0 - ? ` Recovered ${sweep.staleRecovered} stale attempt${sweep.staleRecovered === 1 ? "" : "s"} during health sweep.` - : ""; - emitOrchestratorMessage(ctx, message.missionId, `${summary}${recoveryNote}`.trim(), undefined, undefined, { appendChatMessage: deps.appendChatMessage }); - } catch { - if (ctx.disposed.current) return; - emitOrchestratorMessage( - ctx, - message.missionId, - buildCoordinatorStatusReply(ctx, message.missionId, message.content), - undefined, - undefined, - { appendChatMessage: deps.appendChatMessage } - ); - } - })(); - return; - } - - if (explicitInstruction) { - try { - deps.steerMission({ - missionId: message.missionId, - directive: message.content, - priority: "instruction", - targetStepKey: message.target?.kind === "worker" ? message.target.stepKey ?? null : null - }); - } catch { - // Ignore missing mission / invalid status transitions and preserve chat UX. - } - } - - if (ctx.aiIntegrationService && ctx.projectRoot) { - deps.enqueueChatResponse(chatArgs, recentChatContext); - } else if (explicitInstruction) { - emitOrchestratorMessage( - ctx, - message.missionId, - "Directive received. I will apply it at the next planning/evaluation decision point.", - undefined, - undefined, - { appendChatMessage: deps.appendChatMessage } - ); - } else { - emitOrchestratorMessage( - ctx, - message.missionId, - "Coordinator chat is unavailable right now. Start or resume the run if you want a live response.", - undefined, - undefined, - { appendChatMessage: deps.appendChatMessage } - ); - } -} - -// ── maybeEmitWorkerQueuedNotice ───────────────────────────────── - -export function maybeEmitWorkerQueuedNoticeCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage, - workerLabel: string, - stepKey: string | null, - deps: WorkerDeliveryDeps -): void { - if (message.deliveryState !== "queued") return; - void (async () => { - if (ctx.disposed.current) return; - let canResolveImmediately = false; - if (ctx.agentChatService) { - try { - const latest = getChatMessageById(ctx, message.id) ?? message; - if (latest.deliveryState !== "queued") return; - const context = resolveWorkerDeliveryContextCtx(ctx, latest); - const deliveryMeta = readWorkerDeliveryMetadataCtx(ctx, latest); - const sessionResolution = await resolveWorkerDeliverySessionCtx(ctx, { - message: latest, - context, - deliveryMeta - }); - canResolveImmediately = !!toOptionalString(sessionResolution.sessionId); - } catch (error) { - ctx.logger.debug("ai_orchestrator.worker_delivery_queue_probe_failed", { - missionId: message.missionId, - messageId: message.id, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - const fresh = getChatMessageById(ctx, message.id); - if (!fresh || fresh.deliveryState !== "queued") return; - if (canResolveImmediately) return; - - emitOrchestratorMessage( - ctx, - fresh.missionId, - `Message queued for ${workerLabel}. ADE will deliver it as soon as that worker has a live session or checks its mission messages.`, - stepKey ?? fresh.stepKey ?? null, - undefined, - { appendChatMessage: deps.appendChatMessage } - ); - })(); -} - -// ── routeMessageToWorker ──────────────────────────────────────── - -export function routeMessageToWorkerCtx( - ctx: OrchestratorContext, - message: OrchestratorChatMessage, - deps: WorkerDeliveryDeps -): void { - const target = message.target && message.target.kind === "worker" ? message.target : null; - const workerLabel = - toOptionalString(target?.stepKey) - ?? toOptionalString(target?.stepId) - ?? toOptionalString(target?.attemptId) - ?? toOptionalString(target?.sessionId) - ?? "worker"; - const coordinatorDigest = - message.visibility === "full" - ? `User sent worker guidance to ${workerLabel}: ${clipTextForContext(message.content, 300)}` - : `User sent worker guidance to ${workerLabel}.`; - emitOrchestratorMessage( - ctx, - message.missionId, - coordinatorDigest, - target?.stepKey ?? null, - { - threadId: message.threadId ?? null, - sourceMessageId: message.id, - visibility: message.visibility ?? DEFAULT_WORKER_CHAT_VISIBILITY, - deliveryState: message.deliveryState ?? "queued", - target - }, - { appendChatMessage: deps.appendChatMessage } - ); - if (message.deliveryState === "queued") { - maybeEmitWorkerQueuedNoticeCtx(ctx, message, workerLabel, target?.stepKey ?? null, deps); - } else if (message.deliveryState === "failed") { - emitOrchestratorMessage( - ctx, - message.missionId, - `Worker message to ${workerLabel} failed delivery and needs intervention.`, - target?.stepKey ?? null, - undefined, - { appendChatMessage: deps.appendChatMessage } - ); - } -} diff --git a/apps/desktop/src/main/services/orchestrator/workerTracking.ts b/apps/desktop/src/main/services/orchestrator/workerTracking.ts deleted file mode 100644 index d310e615a..000000000 --- a/apps/desktop/src/main/services/orchestrator/workerTracking.ts +++ /dev/null @@ -1,1881 +0,0 @@ -/** - * workerTracking.ts - * - * Worker state management: getWorkerStates, getWorkerDigest, listWorkerDigests, - * getContextCheckpoint, listLaneDecisions, worker state update functions, - * and updateWorkerStateFromEvent (attempt lifecycle handler). - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import type { - OrchestratorContext, -} from "./orchestratorContext"; -import { - nowIso, - isRecord, - toOptionalString, - clampLimit, - MAX_THREAD_PAGE_SIZE, -} from "./orchestratorContext"; -import { - getMissionIdentity, - emitThreadEvent, - emitOrchestratorMessage, - ensureThreadForTarget, -} from "./chatMessageService"; -import { - stepTitleForMessage, - isRetryQueuedForStep, - extractOutcomeTags, - resolveActiveRuntimeProfile, -} from "./missionLifecycle"; -import { - recordMissionMetricSample, -} from "./metricsAndUsage"; -import { - deletePersistedAttemptRuntimeState, -} from "./recoveryService"; -import { - resolveCallTypeConfig, - resolveOrchestratorModelConfig, -} from "./modelConfigResolver"; -import { - PM_SYSTEM_PREAMBLE, -} from "./coordinatorSession"; -import { modelConfigToServiceModel } from "../../../shared/modelProfiles"; -import { getModelById } from "../../../shared/modelRegistry"; -import type { - OrchestratorWorkerState, - OrchestratorWorkerStatus, - OrchestratorWorkerDigest, - OrchestratorContextCheckpoint, - OrchestratorLaneDecision, - OrchestratorExecutorKind, - OrchestratorRunGraph, - OrchestratorRuntimeEvent, - OrchestratorArtifactKind, - OrchestratorChatMessage, - ListOrchestratorWorkerDigestsArgs, - GetOrchestratorWorkerDigestArgs, - GetOrchestratorContextCheckpointArgs, - ListOrchestratorLaneDecisionsArgs, -} from "../../../shared/types"; -import { - resolveReportArtifactKey, - resolveReportArtifactKind, - resolveReportArtifactMissionType, -} from "../../../shared/proofArtifacts"; - -// ── Worker State Functions ─────────────────────────────────────── - -export function getWorkerStates( - ctx: OrchestratorContext, - args: { runId: string } -): OrchestratorWorkerState[] { - const result: OrchestratorWorkerState[] = []; - for (const state of ctx.workerStates.values()) { - if (state.runId === args.runId) result.push(state); - } - return result; -} - -function resolveEvaluationProvider(attempt: { - metadata?: Record<string, unknown> | null; -}): "claude" | "codex" { - const metadata = isRecord(attempt.metadata) ? attempt.metadata : null; - const modelRef = typeof metadata?.modelId === "string" ? metadata.modelId : null; - if (modelRef) { - const descriptor = getModelById(modelRef); - if (descriptor?.family === "openai") return "codex"; - } - return "claude"; -} - -function resolveWorkerLifecyclePhaseLabel(step: OrchestratorRunGraph["steps"][number] | undefined): "planning" | "validation" | "implementation" { - const stepMeta = isRecord(step?.metadata) ? step?.metadata : {}; - const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; - const readOnlyExecution = stepMeta.readOnlyExecution === true; - if (phaseKey === "planning" || stepType === "planning" || readOnlyExecution) { - return "planning"; - } - if (phaseKey === "validation" || stepType === "validation") { - return "validation"; - } - return "implementation"; -} - -function buildWorkerLifecycleStartMessage(step: OrchestratorRunGraph["steps"][number] | undefined): string { - const phaseLabel = resolveWorkerLifecyclePhaseLabel(step); - if (phaseLabel === "planning") { - return "I’m starting the planning pass now. I’m reviewing the relevant files and I’ll send a concrete plan back to the coordinator."; - } - if (phaseLabel === "validation") { - return "I’m starting validation now. I’m checking the completed work against the mission requirements."; - } - return "I’m starting this task now. I’m making the requested changes and I’ll report back with what changed."; -} - -function buildWorkerLifecycleCompletionMessage( - step: OrchestratorRunGraph["steps"][number] | undefined, - summary: string | null | undefined, -): string { - const compactSummary = typeof summary === "string" - ? summary.replace(/\s+/g, " ").trim() - : ""; - const clippedSummary = compactSummary.length > 0 ? compactSummary.slice(0, 180) : ""; - const phaseLabel = resolveWorkerLifecyclePhaseLabel(step); - if (phaseLabel === "planning") { - return clippedSummary.length > 0 - ? `I finished the planning pass and reported back: ${clippedSummary}` - : "I finished the planning pass and sent the coordinator a concrete plan."; - } - if (phaseLabel === "validation") { - return clippedSummary.length > 0 - ? `I finished validation and reported back: ${clippedSummary}` - : "I finished validation and reported the result back to the coordinator."; - } - return clippedSummary.length > 0 - ? `I finished this task and reported back: ${clippedSummary}` - : "I finished this task and reported the outcome back to the coordinator."; -} - -function buildWorkerLifecycleFailureMessage(attempt: { - errorMessage: string | null; -}): string { - const errorText = typeof attempt.errorMessage === "string" - ? attempt.errorMessage.replace(/\s+/g, " ").trim() - : ""; - if (!errorText.length) { - return "I hit an issue while working on this task and reported it back to the coordinator."; - } - return `I hit an issue while working on this task and reported it back: ${errorText.slice(0, 180)}`; -} - -function resolveRetryExhaustedIntervention(args: { - attempt: OrchestratorRunGraph["attempts"][number]; - step: OrchestratorRunGraph["steps"][number]; - stepTitle: string; -}): { - interventionType: "failed_step" | "provider_unreachable"; - title: string; - body: string; - requestedAction: string; - reasonCode: string; -} { - const attemptMeta = isRecord(args.attempt.metadata) ? args.attempt.metadata : {}; - const softFailureOverride = isRecord(attemptMeta.softFailureOverride) ? attemptMeta.softFailureOverride : {}; - const softCategory = typeof softFailureOverride.category === "string" ? softFailureOverride.category.trim().toLowerCase() : ""; - const providerHint = typeof args.attempt.executorKind === "string" ? args.attempt.executorKind.trim().toLowerCase() : "provider"; - const errorText = typeof args.attempt.errorMessage === "string" ? args.attempt.errorMessage.trim() : ""; - const lowerError = errorText.toLowerCase(); - const providerUnavailable = - softCategory === "missing_auth" - || /needs-auth|authentication required|authentication failed|unauthorized|forbidden|invalid api key|refresh_token_reused|refresh token|sign in again|log out and sign in/i.test(lowerError); - - if (providerUnavailable) { - const providerLabel = providerHint.length > 0 ? providerHint : "provider"; - return { - interventionType: "provider_unreachable", - title: `${providerLabel} needs attention`, - body: `Step ${args.step.stepKey} (${args.stepTitle}) could not continue because ${providerLabel} is unavailable or unauthenticated. Last error: ${errorText || "unknown"}`, - requestedAction: `Restore ${providerLabel} access/authentication, then resume the mission run to retry this worker.`, - reasonCode: "provider_auth_unavailable", - }; - } - - return { - interventionType: "failed_step", - title: `Step "${args.stepTitle}" failed after ${args.step.retryCount} retries`, - body: `Step ${args.step.stepKey} (${args.stepTitle}) exhausted all ${args.step.retryLimit} retries. Last error: ${errorText || "unknown"}`, - requestedAction: "Review and decide whether to retry, skip, or add a workaround.", - reasonCode: "retry_exhausted", - }; -} - -function resolvePlannerPlanMissingIntervention(args: { - attempt: OrchestratorRunGraph["attempts"][number]; - step: OrchestratorRunGraph["steps"][number]; - stepTitle: string; -}): { - interventionType: "failed_step"; - title: string; - body: string; - requestedAction: string; - reasonCode: "planner_plan_missing"; -} { - const errorText = typeof args.attempt.errorMessage === "string" ? args.attempt.errorMessage.trim() : ""; - return { - interventionType: "failed_step", - title: "Planner result missing plan", - body: - errorText || - `Step ${args.step.stepKey} (${args.stepTitle}) finished without returning report_result.plan.markdown, so ADE could not accept the planning attempt as successful.`, - requestedAction: - "Retry planning only after the planner can return report_result.plan.markdown. ADE will not advance until the canonical plan artifact exists.", - reasonCode: "planner_plan_missing", - }; -} - -function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { - ctx: OrchestratorContext; - deps: UpdateWorkerStateDeps; - missionId: string; - attempt: OrchestratorRunGraph["attempts"][number]; - step: OrchestratorRunGraph["steps"][number]; - planArtifactPersisted: boolean; -}): void { - const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {}; - const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; - // Match `extractAndRegisterArtifacts` planning detection: read-only implementation steps must not - // auto-resolve `planner_plan_missing` unless ADE would persist the canonical plan artifact. - const isPlanningStep = stepType === "planning" || stepType === "analysis" || phaseKey === "planning"; - if (!isPlanningStep) return; - - // Only resolve when the canonical plan artifact was actually written and registered in this attempt. - // `report_result.plan.markdown` alone is insufficient: fs.writeFileSync or registerArtifact may have - // failed inside extractAndRegisterArtifacts, and we'd otherwise clear the intervention without a plan. - if (!args.planArtifactPersisted) return; - - const mission = args.ctx.missionService.get(args.missionId); - if (!mission) return; - - const resolvedAt = nowIso(); - for (const intervention of mission.interventions) { - if (intervention.status !== "open" || intervention.interventionType !== "failed_step") continue; - const meta = isRecord(intervention.metadata) ? intervention.metadata : {}; - const reasonCode = typeof meta.reasonCode === "string" ? meta.reasonCode.trim() : ""; - if (reasonCode !== "planner_plan_missing") continue; - // Allow cross-run recovery: any successful planning attempt on this mission can clear a - // stale planner_plan_missing intervention, regardless of which run originally recorded it. - - try { - args.ctx.missionService.resolveIntervention({ - missionId: args.missionId, - interventionId: intervention.id, - status: "resolved", - note: `Auto-resolved after planner returned report_result.plan for step "${stepTitleForMessage(args.step)}".`, - }); - args.deps.recordRuntimeEvent({ - runId: args.attempt.runId, - stepId: args.step.id, - attemptId: args.attempt.id, - sessionId: args.attempt.executorSessionId, - eventType: "intervention_resolved", - eventKey: `intervention_resolved:${intervention.id}:planner_plan_recovered`, - payload: { - interventionId: intervention.id, - reason: "planner_plan_recovered", - recoveredByStepId: args.step.id, - recoveredByStepKey: args.step.stepKey, - resolvedAt, - }, - }); - } catch (error) { - args.ctx.logger.debug("ai_orchestrator.planner_plan_missing_resolve_failed", { - missionId: args.missionId, - runId: args.attempt.runId, - stepId: args.step.id, - interventionId: intervention.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } -} - -function resolveRecoveredFailedStepInterventions(args: { - ctx: OrchestratorContext; - deps: UpdateWorkerStateDeps; - missionId: string; - attempt: OrchestratorRunGraph["attempts"][number]; - step: OrchestratorRunGraph["steps"][number]; -}): void { - const mission = args.ctx.missionService.get(args.missionId); - if (!mission) return; - - const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {}; - const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim() : ""; - const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim() : ""; - const planningLike = - stepMeta.readOnlyExecution === true - || phaseKey.toLowerCase() === "planning" - || stepType.toLowerCase() === "planning"; - const resolvedAt = nowIso(); - - for (const intervention of mission.interventions) { - if (intervention.status !== "open" || (intervention.interventionType !== "failed_step" && intervention.interventionType !== "provider_unreachable")) continue; - - const meta = isRecord(intervention.metadata) ? intervention.metadata : {}; - const interventionRunId = typeof meta.runId === "string" ? meta.runId.trim() : ""; - const interventionStepId = typeof meta.stepId === "string" ? meta.stepId.trim() : ""; - const interventionPhaseKey = typeof meta.phaseKey === "string" ? meta.phaseKey.trim() : ""; - const interventionStepType = typeof meta.stepType === "string" ? meta.stepType.trim() : ""; - const legacyPlanningFailure = - planningLike - && /planning worker exited without reporting a usable plan/i.test( - `${intervention.title} ${intervention.body}`, - ); - const sameRun = interventionRunId.length === 0 || interventionRunId === args.attempt.runId; - const exactStepMatch = interventionStepId.length > 0 && interventionStepId === args.step.id; - const replacementStepMatch = - !exactStepMatch - && sameRun - && planningLike - && interventionPhaseKey.length > 0 - && interventionStepType.length > 0 - && interventionPhaseKey === phaseKey - && interventionStepType === stepType; - - if (!sameRun || (!exactStepMatch && !replacementStepMatch && !legacyPlanningFailure)) continue; - - try { - args.ctx.missionService.resolveIntervention({ - missionId: args.missionId, - interventionId: intervention.id, - status: "resolved", - note: `Auto-resolved after recovery step "${stepTitleForMessage(args.step)}" succeeded.`, - }); - args.deps.recordRuntimeEvent({ - runId: args.attempt.runId, - stepId: args.step.id, - attemptId: args.attempt.id, - sessionId: args.attempt.executorSessionId, - eventType: "intervention_resolved", - eventKey: `intervention_resolved:${intervention.id}:recovery_success`, - payload: { - interventionId: intervention.id, - reason: "recovery_step_succeeded", - recoveredByStepId: args.step.id, - recoveredByStepKey: args.step.stepKey, - resolvedAt, - }, - }); - } catch (error) { - args.ctx.logger.debug("ai_orchestrator.recovery_intervention_resolve_failed", { - missionId: args.missionId, - runId: args.attempt.runId, - stepId: args.step.id, - interventionId: intervention.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } -} - -function appendWorkerThreadLifecycleMessage(args: { - ctx: OrchestratorContext; - deps: UpdateWorkerStateDeps; - missionId: string; - attempt: OrchestratorRunGraph["attempts"][number]; - step: OrchestratorRunGraph["steps"][number] | undefined; - stepTitle: string; - stepKey: string | null; - content: string; - metadataSource: string; -}): void { - const thread = ensureThreadForTarget(args.ctx, { - missionId: args.missionId, - target: { - kind: "worker", - runId: args.attempt.runId, - stepId: args.attempt.stepId, - stepKey: args.stepKey, - attemptId: args.attempt.id, - sessionId: args.attempt.executorSessionId ?? null, - laneId: args.step?.laneId ?? null, - }, - fallbackTitle: `Worker: ${args.stepTitle}`, - }); - - args.deps.appendChatMessage({ - id: randomUUID(), - missionId: args.missionId, - threadId: thread.id, - role: "worker", - content: args.content, - timestamp: nowIso(), - stepKey: args.stepKey, - target: { - kind: "worker", - runId: args.attempt.runId, - stepId: args.attempt.stepId, - stepKey: args.stepKey, - attemptId: args.attempt.id, - sessionId: args.attempt.executorSessionId ?? null, - laneId: args.step?.laneId ?? null, - }, - visibility: "full", - deliveryState: "delivered", - sourceSessionId: args.attempt.executorSessionId ?? null, - attemptId: args.attempt.id, - laneId: args.step?.laneId ?? null, - runId: args.attempt.runId, - metadata: { - source: args.metadataSource, - }, - }); -} - -export function upsertWorkerState( - ctx: OrchestratorContext, - attemptId: string, - update: { - stepId: string; - runId: string; - sessionId?: string | null; - executorKind: OrchestratorExecutorKind; - state: OrchestratorWorkerStatus; - outcomeTags?: string[]; - completedAt?: string | null; - } -): void { - const now = nowIso(); - const existing = ctx.workerStates.get(attemptId); - if (existing) { - existing.state = update.state; - existing.lastHeartbeatAt = now; - if (update.sessionId !== undefined) existing.sessionId = update.sessionId; - if (update.outcomeTags) existing.outcomeTags = update.outcomeTags; - if (update.completedAt !== undefined) existing.completedAt = update.completedAt; - } else { - ctx.workerStates.set(attemptId, { - attemptId, - stepId: update.stepId, - runId: update.runId, - sessionId: update.sessionId ?? null, - executorKind: update.executorKind, - state: update.state, - lastHeartbeatAt: now, - spawnedAt: now, - completedAt: update.completedAt ?? null, - outcomeTags: update.outcomeTags ?? [] - }); - } -} - -/** - * Parse a worker digest row from the database. - */ -export function parseWorkerDigestRow(row: { - id: string; - mission_id: string; - run_id: string; - step_id: string; - attempt_id: string; - lane_id: string | null; - session_id: string | null; - step_key: string | null; - status: string; - summary: string; - files_changed_json: string | null; - tests_run_json: string | null; - warnings_json: string | null; - tokens_json: string | null; - cost_usd: number | null; - suggested_next_actions_json: string | null; - created_at: string; -}): OrchestratorWorkerDigest { - const filesChanged: string[] = (() => { - try { return JSON.parse(row.files_changed_json ?? "[]"); } catch { return []; } - })(); - const testsRun = (() => { - try { - const parsed = JSON.parse(row.tests_run_json ?? "{}"); - return { - passed: Number(parsed.passed ?? 0), - failed: Number(parsed.failed ?? 0), - skipped: Number(parsed.skipped ?? 0), - ...(parsed.summary ? { summary: String(parsed.summary) } : {}) - }; - } catch { - return { passed: 0, failed: 0, skipped: 0 }; - } - })(); - const warnings: string[] = (() => { - try { return JSON.parse(row.warnings_json ?? "[]"); } catch { return []; } - })(); - const tokens = (() => { - try { return JSON.parse(row.tokens_json ?? "null"); } catch { return null; } - })(); - const suggestedNextActions: string[] = (() => { - try { return JSON.parse(row.suggested_next_actions_json ?? "[]"); } catch { return []; } - })(); - - return { - id: row.id, - missionId: row.mission_id, - runId: row.run_id, - stepId: row.step_id, - stepKey: row.step_key, - attemptId: row.attempt_id, - laneId: row.lane_id ?? null, - sessionId: row.session_id ?? null, - status: row.status as OrchestratorWorkerDigest["status"], - summary: row.summary, - filesChanged, - testsRun, - warnings, - tokens, - costUsd: row.cost_usd ?? null, - suggestedNextActions, - createdAt: row.created_at - }; -} - -export function listWorkerDigests( - ctx: OrchestratorContext, - digestArgs: ListOrchestratorWorkerDigestsArgs -): OrchestratorWorkerDigest[] { - const runId = toOptionalString(digestArgs.runId); - const missionId = toOptionalString(digestArgs.missionId); - const stepId = toOptionalString(digestArgs.stepId); - const attemptId = toOptionalString(digestArgs.attemptId); - const laneId = toOptionalString(digestArgs.laneId); - const limit = clampLimit(digestArgs.limit, 50, MAX_THREAD_PAGE_SIZE); - - const clauses: string[] = []; - const params: (string | number)[] = []; - - if (runId) { - clauses.push("wd.run_id = ?"); - params.push(runId); - } - if (missionId) { - clauses.push("wd.run_id IN (SELECT id FROM orchestrator_runs WHERE mission_id = ?)"); - params.push(missionId); - } - if (stepId) { - clauses.push("wd.step_id = ?"); - params.push(stepId); - } - if (attemptId) { - clauses.push("wd.attempt_id = ?"); - params.push(attemptId); - } - if (laneId) { - clauses.push("wd.lane_id = ?"); - params.push(laneId); - } - - const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; - params.push(limit); - - const rows = ctx.db.all( - `SELECT * FROM orchestrator_worker_digests wd ${whereClause} ORDER BY wd.created_at DESC LIMIT ?`, - params - ) as Array<any>; - - return rows.map((row) => parseWorkerDigestRow(row)); -} - -export function getWorkerDigest( - ctx: OrchestratorContext, - digestArgs: GetOrchestratorWorkerDigestArgs -): OrchestratorWorkerDigest | null { - const digestId = toOptionalString(digestArgs.digestId); - const missionId = toOptionalString(digestArgs.missionId); - if (!digestId || !missionId) return null; - - const row = ctx.db.get( - ` - SELECT * - FROM orchestrator_worker_digests - WHERE id = ? - AND run_id IN (SELECT id FROM orchestrator_runs WHERE mission_id = ?) - LIMIT 1 - `, - [digestId, missionId] - ) as any; - - return row ? parseWorkerDigestRow(row) : null; -} - -export function getContextCheckpoint( - ctx: OrchestratorContext, - checkpointArgs: GetOrchestratorContextCheckpointArgs -): OrchestratorContextCheckpoint | null { - const missionId = toOptionalString(checkpointArgs.missionId); - if (!missionId) return null; - - const checkpointId = toOptionalString(checkpointArgs.checkpointId); - - const row = ctx.db.get( - checkpointId - ? `SELECT * FROM orchestrator_context_checkpoints WHERE id = ? AND mission_id = ? LIMIT 1` - : `SELECT * FROM orchestrator_context_checkpoints WHERE mission_id = ? ORDER BY created_at DESC LIMIT 1`, - checkpointId ? [checkpointId, missionId] : [missionId] - ) as any; - - if (!row) return null; - - const sourceData = (() => { - try { - const meta = JSON.parse(row.metadata_json ?? "{}"); - return { - digestCount: Number(meta.digestCount ?? 0), - chatMessageCount: Number(meta.chatMessageCount ?? 0), - compressedMessageCount: Number(meta.compressedMessageCount ?? 0) - }; - } catch { - return { digestCount: 0, chatMessageCount: 0, compressedMessageCount: 0 }; - } - })(); - - return { - id: row.id, - missionId: row.mission_id, - runId: row.run_id ?? null, - trigger: (row.checkpoint_type ?? row.trigger ?? "manual") as OrchestratorContextCheckpoint["trigger"], - summary: row.content ?? row.summary ?? "", - source: sourceData, - createdAt: row.created_at - }; -} - -export function listLaneDecisions( - ctx: OrchestratorContext, - laneArgs: ListOrchestratorLaneDecisionsArgs -): OrchestratorLaneDecision[] { - const missionId = toOptionalString(laneArgs.missionId); - const runId = toOptionalString(laneArgs.runId); - const limit = clampLimit(laneArgs.limit, 50, MAX_THREAD_PAGE_SIZE); - - const clauses: string[] = []; - const params: (string | number)[] = []; - - if (missionId) { - clauses.push("ld.mission_id = ?"); - params.push(missionId); - } - if (runId) { - clauses.push("ld.run_id = ?"); - params.push(runId); - } - - const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; - params.push(limit); - - const rows = ctx.db.all( - `SELECT * FROM orchestrator_lane_decisions ld ${whereClause} ORDER BY ld.created_at DESC LIMIT ?`, - params - ) as Array<any>; - - return rows.map((row) => { - const metadata = (() => { - try { return JSON.parse(row.metadata_json ?? "null"); } catch { return null; } - })(); - const ruleHits: string[] = (() => { - try { return JSON.parse(row.rule_hits_json ?? "[]"); } catch { return []; } - })(); - return { - id: row.id, - missionId: row.mission_id, - runId: row.run_id ?? null, - stepId: row.step_id ?? null, - stepKey: row.step_key ?? null, - laneId: row.lane_id ?? null, - decisionType: (row.decision_type ?? "proposal") as OrchestratorLaneDecision["decisionType"], - validatorOutcome: (row.validator_outcome ?? "pass") as OrchestratorLaneDecision["validatorOutcome"], - ruleHits, - rationale: row.rationale ?? row.reasoning ?? "", - metadata, - createdAt: row.created_at - }; - }); -} - -// ── Worker Digest Functions ────────────────────────────────────── - -export function buildWorkerDigestFromAttempt(args: { - graph: OrchestratorRunGraph; - attempt: OrchestratorRunGraph["attempts"][number]; -}): OrchestratorWorkerDigest { - const step = args.graph.steps.find((entry) => entry.id === args.attempt.stepId); - const envelope = args.attempt.resultEnvelope; - const outputs = envelope?.outputs ?? null; - const filesChangedRaw = outputs?.filesChanged ?? outputs?.files_changed ?? []; - const filesChanged = Array.isArray(filesChangedRaw) - ? filesChangedRaw.map((entry) => String(entry ?? "").trim()).filter(Boolean) - : []; - const testsRun = { - passed: Math.max(0, Math.floor(Number(outputs?.testsPassed ?? outputs?.tests_passed) || 0)), - failed: Math.max(0, Math.floor(Number(outputs?.testsFailed ?? outputs?.tests_failed) || 0)), - skipped: Math.max(0, Math.floor(Number(outputs?.testsSkipped ?? outputs?.tests_skipped) || 0)), - summary: typeof outputs?.testsSummary === "string" - ? outputs.testsSummary - : typeof outputs?.tests_summary === "string" - ? outputs.tests_summary - : null - }; - const tokensInput = Number(outputs?.inputTokens ?? outputs?.input_tokens ?? outputs?.tokensInput ?? outputs?.tokens_input); - const tokensOutput = Number(outputs?.outputTokens ?? outputs?.output_tokens ?? outputs?.tokensOutput ?? outputs?.tokens_output); - const tokensTotal = Number(outputs?.totalTokens ?? outputs?.total_tokens ?? outputs?.tokensTotal ?? outputs?.tokens_total); - const tokens = - Number.isFinite(tokensInput) || Number.isFinite(tokensOutput) || Number.isFinite(tokensTotal) - ? { - input: Number.isFinite(tokensInput) ? Math.max(0, Math.floor(tokensInput)) : undefined, - output: Number.isFinite(tokensOutput) ? Math.max(0, Math.floor(tokensOutput)) : undefined, - total: Number.isFinite(tokensTotal) ? Math.max(0, Math.floor(tokensTotal)) : undefined - } - : null; - const costUsdRaw = Number(outputs?.costUsd ?? outputs?.cost_usd ?? outputs?.usdCost ?? outputs?.usd_cost); - const costUsd = Number.isFinite(costUsdRaw) ? costUsdRaw : null; - const status = - args.attempt.status === "running" - || args.attempt.status === "succeeded" - || args.attempt.status === "failed" - || args.attempt.status === "blocked" - || args.attempt.status === "queued" - ? args.attempt.status - : "queued"; - const summary = - typeof envelope?.summary === "string" && envelope.summary.trim().length - ? envelope.summary.trim() - : args.attempt.status === "running" - ? `Worker started on ${step ? stepTitleForMessage(step) : args.attempt.stepId}.` - : args.attempt.errorMessage ?? `Step ${step?.stepKey ?? args.attempt.stepId} finished with status ${args.attempt.status}.`; - const warnings = Array.isArray(envelope?.warnings) - ? envelope.warnings.map((entry) => String(entry ?? "").trim()).filter(Boolean) - : []; - return { - id: randomUUID(), - missionId: args.graph.run.missionId, - runId: args.attempt.runId, - stepId: args.attempt.stepId, - stepKey: step?.stepKey ?? null, - attemptId: args.attempt.id, - laneId: step?.laneId ?? null, - sessionId: args.attempt.executorSessionId ?? null, - status, - summary, - filesChanged, - testsRun, - warnings, - tokens, - costUsd, - suggestedNextActions: - args.attempt.status === "failed" - ? ["Investigate failure", "Review logs", "Retry with guidance"] - : args.attempt.status === "running" - ? ["Monitor progress"] - : [], - createdAt: nowIso() - }; -} - -export function emitWorkerDigest( - ctx: OrchestratorContext, - digest: OrchestratorWorkerDigest -): OrchestratorWorkerDigest { - const missionIdentity = getMissionIdentity(ctx, digest.missionId); - if (!missionIdentity) return digest; - ctx.db.run( - ` - insert into orchestrator_worker_digests( - id, - project_id, - mission_id, - run_id, - step_id, - step_key, - attempt_id, - lane_id, - session_id, - status, - summary, - files_changed_json, - tests_run_json, - warnings_json, - tokens_json, - cost_usd, - suggested_next_actions_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - digest.id, - missionIdentity.projectId, - digest.missionId, - digest.runId, - digest.stepId, - digest.stepKey, - digest.attemptId, - digest.laneId, - digest.sessionId, - digest.status, - digest.summary, - JSON.stringify(digest.filesChanged ?? []), - JSON.stringify(digest.testsRun ?? { passed: 0, failed: 0, skipped: 0 }), - JSON.stringify(digest.warnings ?? []), - digest.tokens ? JSON.stringify(digest.tokens) : null, - digest.costUsd ?? null, - JSON.stringify(digest.suggestedNextActions ?? []), - digest.createdAt - ] - ); - emitThreadEvent(ctx, { - type: "worker_digest_updated", - missionId: digest.missionId, - runId: digest.runId, - threadId: null, - reason: "worker_digest", - metadata: { - digestId: digest.id, - attemptId: digest.attemptId, - stepId: digest.stepId - } - }); - return digest; -} - -// ── Artifact Extraction ────────────────────────────────────────── - -export function extractAndRegisterArtifacts( - ctx: OrchestratorContext, - args: { - graph: OrchestratorRunGraph; - attempt: OrchestratorRunGraph["attempts"][number]; - } -): { planArtifactPersisted: boolean } { - let planArtifactPersisted = false; - try { - const { graph, attempt } = args; - const envelope = attempt.resultEnvelope; - if (!envelope) return { planArtifactPersisted }; - const outputs = isRecord(envelope.outputs) ? envelope.outputs : {}; - - const step = graph.steps.find((s) => s.id === attempt.stepId); - const stepMeta = step && isRecord(step.metadata) ? step.metadata : {}; - const planStep = isRecord(stepMeta.planStep) ? stepMeta.planStep : null; - const lastResultReport = isRecord(stepMeta.lastResultReport) ? stepMeta.lastResultReport : null; - const artifactHints: string[] = Array.isArray(planStep?.artifactHints) - ? (planStep!.artifactHints as unknown[]).map((h) => String(h ?? "").trim()).filter(Boolean) - : []; - const declaredKeySet = new Set(artifactHints); - const registeredKeys = new Set<string>(); - - const register = (artifactKey: string, kind: OrchestratorArtifactKind, value: string, metadata?: Record<string, unknown>) => { - const isDeclared = declaredKeySet.has(artifactKey); - ctx.orchestratorService.registerArtifact({ - missionId: graph.run.missionId, - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - artifactKey, - kind, - value, - metadata: metadata ?? {}, - declared: isDeclared - }); - registeredKeys.add(artifactKey); - ctx.logger.debug("ai_orchestrator.artifact_registered", { - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - artifactKey, - kind, - declared: isDeclared - }); - }; - - const registerMissionArtifact = (args: { - artifactType: "plan" | "summary" | "note" | "patch" | "link" | "pr" | "test_report" | "screenshot" | "browser_verification" | "browser_trace" | "video_recording" | "console_logs"; - title: string; - description?: string | null; - uri?: string | null; - metadata?: Record<string, unknown>; - }) => { - try { - ctx.missionService.addArtifact({ - missionId: graph.run.missionId, - artifactType: args.artifactType, - title: args.title, - description: args.description, - uri: args.uri, - laneId: step?.laneId ?? null, - metadata: { - runId: attempt.runId, - stepId: attempt.stepId, - stepKey: step?.stepKey ?? null, - attemptId: attempt.id, - source: "orchestrator_worker_tracking", - ...(args.metadata ?? {}), - }, - createdBy: "system", - actor: "system", - }); - } catch (error) { - ctx.logger.debug("ai_orchestrator.mission_artifact_register_failed", { - missionId: graph.run.missionId, - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - title: args.title, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - - const reportedSummary = - typeof lastResultReport?.summary === "string" ? lastResultReport.summary.trim() : ""; - const stepSummary = reportedSummary || (typeof envelope.summary === "string" ? envelope.summary.trim() : ""); - if (stepSummary.length > 0) { - register("step_summary", "custom", stepSummary, { - title: "Step summary", - summary: stepSummary, - }); - } - - // Extract file artifacts from filesChanged / filesModified - const filesChangedRaw = outputs.filesChanged ?? outputs.files_changed ?? outputs.filesModified ?? outputs.files_modified; - if (Array.isArray(filesChangedRaw) && filesChangedRaw.length > 0) { - const files = filesChangedRaw.map((f) => String(f ?? "").trim()).filter(Boolean); - if (files.length > 0) { - register("files_changed", "file", files.join(", "), { fileCount: files.length, files: files.slice(0, 50) }); - } - } - - // Extract test report artifacts - const testsPassed = Number(outputs.testsPassed ?? outputs.tests_passed); - const testsFailed = Number(outputs.testsFailed ?? outputs.tests_failed); - const testsSkipped = Number(outputs.testsSkipped ?? outputs.tests_skipped); - if (Number.isFinite(testsPassed) || Number.isFinite(testsFailed)) { - const testSummary = typeof outputs.testsSummary === "string" - ? outputs.testsSummary - : typeof outputs.tests_summary === "string" - ? outputs.tests_summary - : `passed: ${testsPassed || 0}, failed: ${testsFailed || 0}, skipped: ${testsSkipped || 0}`; - register("test_results", "test_report", testSummary, { - passed: Number.isFinite(testsPassed) ? testsPassed : 0, - failed: Number.isFinite(testsFailed) ? testsFailed : 0, - skipped: Number.isFinite(testsSkipped) ? testsSkipped : 0 - }); - } - - // Extract branch artifact - const branchName = outputs.branchName ?? outputs.branch_name ?? outputs.branch; - if (typeof branchName === "string" && branchName.trim().length > 0) { - register("feature_branch", "branch", branchName.trim()); - } - - // Extract PR artifact - const prUrl = outputs.prUrl ?? outputs.pr_url ?? outputs.pullRequestUrl ?? outputs.pull_request_url; - if (typeof prUrl === "string" && prUrl.trim().length > 0) { - register("implementation_pr", "pr", prUrl.trim()); - } - - const reportArtifacts = Array.isArray(lastResultReport?.artifacts) ? lastResultReport.artifacts : []; - reportArtifacts.forEach((entry, index) => { - const artifact = isRecord(entry) ? entry : null; - const rawTitle = artifact && typeof artifact.title === "string" ? artifact.title.trim() : ""; - const rawType = artifact && typeof artifact.type === "string" ? artifact.type.trim().toLowerCase() : ""; - const rawUri = artifact && typeof artifact.uri === "string" ? artifact.uri.trim() : ""; - const title = rawTitle || `Worker artifact ${index + 1}`; - const metadata = isRecord(artifact?.metadata) ? artifact.metadata : {}; - const artifactKey = resolveReportArtifactKey({ - type: rawType, - title: title, - metadata, - index, - }); - const kind = resolveReportArtifactKind({ - type: rawType, - artifactKey, - uri: rawUri, - metadata, - }); - const value = rawUri.length - ? rawUri - : artifact?.metadata != null - ? JSON.stringify(artifact.metadata) - : title; - register(artifactKey, kind, value, { - title, - type: rawType || "artifact", - ...(metadata ?? {}), - }); - const missionArtifactType = resolveReportArtifactMissionType({ - type: rawType, - artifactKey, - metadata, - }); - if (missionArtifactType) { - registerMissionArtifact({ - artifactType: missionArtifactType, - title, - description: rawUri.length > 0 ? null : title, - uri: rawUri.length > 0 ? rawUri : null, - metadata: { - artifactKey, - kind, - sourceType: rawType || "artifact", - ...metadata, - }, - }); - } - }); - - // Match remaining output keys against declared artifactHints - for (const hintKey of artifactHints) { - if (registeredKeys.has(hintKey)) continue; - // Check if outputs has a matching key (camelCase or snake_case) - const value = outputs[hintKey] ?? outputs[hintKey.replace(/_([a-z])/g, (_, c) => c.toUpperCase())]; - if (value != null) { - const strValue = typeof value === "string" ? value : JSON.stringify(value); - register(hintKey, "custom", strValue, { raw: value }); - } - } - - // Register planning artifact for planning steps - const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; - const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; - const isPlanningStep = stepType === "planning" || stepType === "analysis" || phaseKey === "planning"; - if (isPlanningStep && !registeredKeys.has("plan")) { - const reportedPlan = isRecord(lastResultReport?.plan) ? lastResultReport.plan : null; - const planSummary = - typeof reportedPlan?.summary === "string" && reportedPlan.summary.trim().length > 0 - ? reportedPlan.summary.trim() - : typeof envelope.summary === "string" && envelope.summary.trim().length > 0 - ? envelope.summary.trim() - : "Planning step completed."; - const planMarkdown = - typeof reportedPlan?.markdown === "string" && reportedPlan.markdown.trim().length > 0 - ? reportedPlan.markdown.trim() - : ""; - const requestedPlanPath = - typeof reportedPlan?.artifactPath === "string" && reportedPlan.artifactPath.trim().length > 0 - ? reportedPlan.artifactPath.trim() - : typeof outputs.planPath === "string" && outputs.planPath.trim().length > 0 - ? outputs.planPath.trim() - : ""; - const planValue = - requestedPlanPath.startsWith(".ade/plans/") - ? requestedPlanPath - : ".ade/plans/mission-plan.md"; - const laneWorktreeRow = step?.laneId - ? ctx.db.get<{ worktree_path: string | null }>( - `select worktree_path from lanes where id = ? limit 1`, - [step.laneId] - ) - : null; - const worktreePath = typeof laneWorktreeRow?.worktree_path === "string" && laneWorktreeRow.worktree_path.trim().length > 0 - ? laneWorktreeRow.worktree_path.trim() - : ctx.projectRoot ?? null; - const absolutePlanPath = worktreePath - ? (path.isAbsolute(planValue) ? planValue : path.join(worktreePath, planValue)) - : (path.isAbsolute(planValue) ? planValue : null); - if (absolutePlanPath && planMarkdown.length > 0) { - fs.mkdirSync(path.dirname(absolutePlanPath), { recursive: true }); - fs.writeFileSync(absolutePlanPath, planMarkdown.endsWith("\n") ? planMarkdown : `${planMarkdown}\n`, "utf8"); - register("plan", "custom", planValue, { - planType: "mission_plan", - source: "ade_persisted_plan", - summary: planSummary, - absolutePath: absolutePlanPath, - }); - registerMissionArtifact({ - artifactType: "plan", - title: "Mission plan", - description: planSummary, - uri: planValue, - metadata: { - planType: "mission_plan", - absolutePath: absolutePlanPath, - }, - }); - planArtifactPersisted = true; - } else { - const awaitingUserInput = isRecord(stepMeta.awaitingUserInput) ? stepMeta.awaitingUserInput : null; - const awaitingSource = typeof awaitingUserInput?.source === "string" ? awaitingUserInput.source : ""; - const awaitingQuestion = typeof awaitingUserInput?.question === "string" ? awaitingUserInput.question.trim() : ""; - const plannerQuestionReason = - awaitingSource === "planner_required_question_missing" || awaitingSource === "planner_natural_question" - ? awaitingSource - : null; - if (plannerQuestionReason && awaitingQuestion.length > 0) { - const naturalPlannerQuestion = awaitingQuestion; - const questionLink = isRecord(awaitingUserInput?.questionLink) ? awaitingUserInput.questionLink : null; - const mission = ctx.missionService.get(graph.run.missionId); - const existing = mission?.interventions.some((entry) => { - if (entry.status !== "open" || entry.interventionType !== "manual_input") return false; - const metadata = isRecord(entry.metadata) ? entry.metadata : null; - return (metadata?.reasonCode === "planner_natural_question" || metadata?.reasonCode === "planner_required_question_missing") && metadata?.attemptId === attempt.id; - }) ?? false; - if (!existing) { - const intervention = ctx.missionService.addIntervention({ - missionId: graph.run.missionId, - interventionType: "manual_input", - title: naturalPlannerQuestion.length > 96 ? "Planner question ready" : `Question: ${naturalPlannerQuestion}`, - body: naturalPlannerQuestion, - requestedAction: "Answer the planning question to retry the planner with your guidance.", - metadata: { - source: plannerQuestionReason, - reasonCode: plannerQuestionReason, - question: naturalPlannerQuestion, - runId: attempt.runId, - stepId: step?.id ?? attempt.stepId, - stepKey: step?.stepKey ?? null, - attemptId: attempt.id, - sessionId: attempt.executorSessionId ?? null, - phaseKey: phaseKey || null, - stepType: stepType || null, - threadId: typeof questionLink?.threadId === "string" ? questionLink.threadId : null, - messageId: typeof questionLink?.messageId === "string" ? questionLink.messageId : null, - replyTo: typeof questionLink?.replyTo === "string" ? questionLink.replyTo : null, - }, - }); - ctx.orchestratorService.appendRuntimeEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - sessionId: attempt.executorSessionId ?? null, - eventType: "intervention_opened", - eventKey: `intervention_opened:${intervention.id}`, - payload: { - interventionId: intervention.id, - interventionType: intervention.interventionType, - reason: plannerQuestionReason, - question: naturalPlannerQuestion, - }, - }); - try { - ctx.orchestratorService.pauseRun({ - runId: attempt.runId, - reason: `${plannerQuestionReason === "planner_required_question_missing" ? "Required planning question" : "Planning question"} needs an answer: ${naturalPlannerQuestion.slice(0, 140)}`, - metadata: { - pendingPlannerQuestion: { - interventionId: intervention.id, - stepId: step?.id ?? attempt.stepId, - stepKey: step?.stepKey ?? null, - attemptId: attempt.id, - question: naturalPlannerQuestion, - }, - }, - }); - } catch { - // Best effort: the manual-input intervention is the durable blocker. - } - } - return { planArtifactPersisted }; - } - const missingPlanDetail = "Planning worker completed without returning a usable plan payload in report_result.plan.markdown."; - ctx.logger.warn("ai_orchestrator.plan_artifact_missing", { - missionId: graph.run.missionId, - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - expectedPlanPath: planValue, - absolutePlanPath, - }); - ctx.orchestratorService.appendTimelineEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - eventType: "planning_artifact_missing", - reason: "plan_payload_missing", - detail: { - expectedPlanPath: planValue, - absolutePlanPath, - summary: planSummary, - }, - }); - const intervention = ctx.missionService.addIntervention({ - missionId: graph.run.missionId, - interventionType: "failed_step", - title: "Planner result missing plan", - body: missingPlanDetail, - requestedAction: "Retry planning only after the planner can return report_result.plan.markdown. ADE will persist the canonical plan artifact after completion.", - metadata: { - runId: attempt.runId, - stepId: step?.id ?? attempt.stepId, - stepKey: step?.stepKey ?? null, - ...(phaseKey.length > 0 ? { phaseKey } : {}), - ...(stepType.length > 0 ? { stepType } : {}), - reasonCode: "planner_plan_missing", - expectedPlanPath: planValue, - }, - }); - ctx.orchestratorService.appendRuntimeEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - sessionId: attempt.executorSessionId ?? null, - eventType: "intervention_opened", - eventKey: `intervention_opened:${intervention.id}`, - payload: { - interventionId: intervention.id, - interventionType: intervention.interventionType, - reason: "planner_plan_missing", - expectedPlanPath: planValue, - }, - }); - } - } - - // Validate: warn for any declared hints that were not produced - const missingHints = artifactHints.filter((hint) => !registeredKeys.has(hint)); - if (missingHints.length > 0) { - ctx.logger.info("ai_orchestrator.artifact_hints_missing", { - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - stepKey: step?.stepKey ?? null, - missingHints, - producedKeys: Array.from(registeredKeys) - }); - // Record a timeline event for the missing artifacts - ctx.orchestratorService.appendTimelineEvent({ - runId: attempt.runId, - stepId: attempt.stepId, - attemptId: attempt.id, - eventType: "artifact_hints_missing", - reason: "artifact_validation", - detail: { - missingHints, - producedKeys: Array.from(registeredKeys), - totalDeclared: artifactHints.length, - totalProduced: registeredKeys.size - } - }); - } - } catch (error) { - ctx.logger.debug("ai_orchestrator.artifact_extraction_failed", { - runId: args.attempt.runId, - stepId: args.attempt.stepId, - attemptId: args.attempt.id, - error: error instanceof Error ? error.message : String(error) - }); - } - return { planArtifactPersisted }; -} - -// ── updateWorkerStateFromEvent ─────────────────────────────────── - -export type UpdateWorkerStateDeps = { - recordRuntimeEvent: (...args: any[]) => void; - evaluateWorkerPlan: (...args: any[]) => Promise<any>; - propagateHandoffContext: (...args: any[]) => void; - steerMission: (...args: any[]) => any; - handleInterventionWithAI: (...args: any[]) => Promise<any>; - appendChatMessage: (message: OrchestratorChatMessage) => OrchestratorChatMessage; -}; - -export function updateWorkerStateFromEventCtx( - ctx: OrchestratorContext, - event: OrchestratorRuntimeEvent, - deps: UpdateWorkerStateDeps -): void { - if (event.type !== "orchestrator-attempt-updated" || !event.attemptId || !event.runId) return; - - try { - const graph = ctx.orchestratorService.getRunGraph({ runId: event.runId, timelineLimit: 0 }); - const attempt = graph.attempts.find((a) => a.id === event.attemptId); - if (!attempt) return; - - const stepForAttempt = graph.steps.find((s) => s.id === attempt.stepId); - const stepTitle = stepForAttempt?.title ?? stepForAttempt?.stepKey ?? attempt.stepId.slice(0, 8); - const stepKey = stepForAttempt?.stepKey ?? null; - - if (attempt.status === "running") { - const attemptMeta = isRecord(attempt.metadata) ? attempt.metadata : {}; - const workerSessionKind = typeof attemptMeta.workerSessionKind === "string" ? attemptMeta.workerSessionKind.trim() : ""; - const nextWorkerState = workerSessionKind === "managed_chat" ? "initializing" : "working"; - const existing = ctx.workerStates.get(attempt.id); - const shouldAnnounceStart = !existing || (existing.state !== "working" && existing.state !== "initializing"); - upsertWorkerState(ctx, attempt.id, { - stepId: attempt.stepId, - runId: attempt.runId, - sessionId: attempt.executorSessionId, - executorKind: attempt.executorKind, - state: nextWorkerState - }); - if (shouldAnnounceStart) { - emitOrchestratorMessage( - ctx, - graph.run.missionId, - `Started worker "${stepTitle}". It is now working on this step.`, - stepKey, - null, - { appendChatMessage: deps.appendChatMessage } - ); - emitWorkerDigest(ctx, buildWorkerDigestFromAttempt({ graph, attempt })); - - try { - ensureThreadForTarget(ctx, { - missionId: graph.run.missionId, - target: { - kind: "worker", - runId: attempt.runId, - stepId: attempt.stepId, - stepKey: stepKey, - attemptId: attempt.id, - sessionId: attempt.executorSessionId ?? null, - laneId: stepForAttempt?.laneId ?? null, - }, - fallbackTitle: `Worker: ${stepTitle}`, - }); - } catch (_threadErr) { - /* best-effort */ - } - appendWorkerThreadLifecycleMessage({ - ctx, - deps, - missionId: graph.run.missionId, - attempt, - step: stepForAttempt, - stepTitle, - stepKey, - content: buildWorkerLifecycleStartMessage(stepForAttempt), - metadataSource: "worker_lifecycle_started", - }); - } - recordMissionMetricSample(ctx, { - missionId: graph.run.missionId, - runId: attempt.runId, - attemptId: attempt.id, - metric: "implementation", - value: 1, - unit: "attempt", - metadata: { - status: attempt.status, - executorKind: attempt.executorKind - } - }); - } else if (attempt.status === "succeeded") { - ctx.attemptRuntimeTrackers.delete(attempt.id); - deletePersistedAttemptRuntimeState(ctx, attempt.id); - const outcomeTags = extractOutcomeTags(attempt); - const digest = emitWorkerDigest(ctx, buildWorkerDigestFromAttempt({ graph, attempt })); - const existing = ctx.workerStates.get(attempt.id); - const shouldAnnounceCompletion = !existing || existing.state !== "completed"; - upsertWorkerState(ctx, attempt.id, { - stepId: attempt.stepId, - runId: attempt.runId, - sessionId: attempt.executorSessionId, - executorKind: attempt.executorKind, - state: "completed", - outcomeTags, - completedAt: attempt.completedAt ?? nowIso() - }); - const resultSummary = attempt.resultEnvelope?.summary - ? ` — ${attempt.resultEnvelope.summary.slice(0, 120)}` - : ""; - if (shouldAnnounceCompletion) { - emitOrchestratorMessage( - ctx, - graph.run.missionId, - `Finished "${stepTitle}"${resultSummary}`, - stepKey, - null, - { appendChatMessage: deps.appendChatMessage } - ); - appendWorkerThreadLifecycleMessage({ - ctx, - deps, - missionId: graph.run.missionId, - attempt, - step: stepForAttempt, - stepTitle, - stepKey, - content: buildWorkerLifecycleCompletionMessage(stepForAttempt, attempt.resultEnvelope?.summary), - metadataSource: "worker_lifecycle_completed", - }); - } - if (digest.tokens?.total != null) { - recordMissionMetricSample(ctx, { - missionId: graph.run.missionId, - runId: attempt.runId, - attemptId: attempt.id, - metric: "tokens", - value: digest.tokens.total, - unit: "tokens" - }); - } - if (digest.costUsd != null) { - recordMissionMetricSample(ctx, { - missionId: graph.run.missionId, - runId: attempt.runId, - attemptId: attempt.id, - metric: "cost", - value: digest.costUsd, - unit: "usd" - }); - } - - // Extract and register artifacts from the worker result envelope. - const artifactExtraction = extractAndRegisterArtifacts(ctx, { graph, attempt }); - - // Evaluation loop: evaluate step based on active runtime profile. - const step = graph.steps.find((s) => s.id === attempt.stepId); - if (step) { - resolveRecoveredFailedStepInterventions({ - ctx, - deps, - missionId: graph.run.missionId, - attempt, - step, - }); - resolvePlannerPlanMissingInterventionsAfterPlanningSuccess({ - ctx, - deps, - missionId: graph.run.missionId, - attempt, - step, - planArtifactPersisted: artifactExtraction.planArtifactPersisted, - }); - // After auto-resolving interventions, transition mission back to - // in_progress if it was intervention_required and no open interventions - // remain. Mirrors the steering-directive resume path in aiOrchestratorService. - try { - const postResolveMission = ctx.missionService.get(graph.run.missionId); - if (postResolveMission?.status === "intervention_required") { - const stillOpen = postResolveMission.interventions.some((iv) => iv.status === "open"); - if (!stillOpen) { - ctx.missionService.update({ missionId: graph.run.missionId, status: "in_progress" }); - } - } - } catch (transitionError) { - ctx.logger.debug("ai_orchestrator.intervention_resolved_mission_transition_failed", { - missionId: graph.run.missionId, - runId: attempt.runId, - error: transitionError instanceof Error ? transitionError.message : String(transitionError), - }); - } - } - if (step && ctx.aiIntegrationService) { - const runtimeProfile = ctx.runRuntimeProfiles.get(attempt.runId) ?? resolveActiveRuntimeProfile(ctx, graph.run.missionId); - const isFinalStep = graph.steps.every( - (s) => s.id === step.id || s.status === "succeeded" || s.status === "failed" || s.status === "skipped" - ); - const stepMeta = isRecord(step.metadata) ? step.metadata : {}; - const completionCriteria = typeof stepMeta.completionCriteria === "string" ? stepMeta.completionCriteria : ""; - const hasCriteria = completionCriteria.length > 0 && completionCriteria !== "step_done"; - - const coordForEval = ctx.coordinatorAgents.get(attempt.runId); - if (!coordForEval?.isAlive && hasCriteria && (runtimeProfile.evaluation.evaluateEveryStep || isFinalStep)) { - deps.evaluateWorkerPlan({ - attemptId: attempt.id, - workerPlan: { - stepKey: step.stepKey, - status: step.status, - outcomeTags, - completionCriteria, - resultSummary: attempt.resultEnvelope?.summary ?? null - }, - provider: resolveEvaluationProvider(attempt) - }).then((evalResult: any) => { - emitOrchestratorMessage( - ctx, - graph.run.missionId, - evalResult.approved - ? `Step "${stepTitleForMessage(step)}" passed evaluation. ${evalResult.feedback}` - : `Step "${stepTitleForMessage(step)}" failed evaluation: ${evalResult.feedback}.`, - step.stepKey, - null, - { appendChatMessage: deps.appendChatMessage } - ); - if (!evalResult.approved) { - ctx.logger.info("ai_orchestrator.step_evaluation_rejected", { - runId: attempt.runId, - stepId: step.id, - feedback: evalResult.feedback - }); - } - }).catch((error: unknown) => { - ctx.logger.debug("ai_orchestrator.step_evaluation_failed", { - runId: attempt.runId, - stepId: step.id, - error: error instanceof Error ? error.message : String(error) - }); - }); - } - } - - // Propagate structured handoff context to downstream steps - deps.propagateHandoffContext({ - runId: attempt.runId, - completedStepId: attempt.stepId, - digest - }); - - // Transition handling is AI-driven via runtime attempt_completed events. - } else if (attempt.status === "failed") { - ctx.attemptRuntimeTrackers.delete(attempt.id); - deletePersistedAttemptRuntimeState(ctx, attempt.id); - const outcomeTags = extractOutcomeTags(attempt); - const digest = emitWorkerDigest(ctx, buildWorkerDigestFromAttempt({ graph, attempt })); - const existing = ctx.workerStates.get(attempt.id); - const shouldAnnounceFailure = !existing || existing.state !== "failed"; - upsertWorkerState(ctx, attempt.id, { - stepId: attempt.stepId, - runId: attempt.runId, - sessionId: attempt.executorSessionId, - executorKind: attempt.executorKind, - state: "failed", - outcomeTags, - completedAt: attempt.completedAt ?? nowIso() - }); - - // Check for retry exhaustion → create real intervention then trigger AI - const step = graph.steps.find((s) => s.id === attempt.stepId); - const retryQueued = isRetryQueuedForStep(step); - const retriesLeft = step ? Math.max(0, step.retryLimit - step.retryCount) : 0; - if (shouldAnnounceFailure) { - emitOrchestratorMessage( - ctx, - graph.run.missionId, - `Step "${stepTitle}" failed: ${attempt.errorMessage ?? "unknown error"}. ${ - retryQueued - ? `Retry scheduled${retriesLeft > 0 ? ` (${retriesLeft} retries left).` : "."}` - : "No retries remaining." - }`, - stepKey, - null, - { appendChatMessage: deps.appendChatMessage } - ); - appendWorkerThreadLifecycleMessage({ - ctx, - deps, - missionId: graph.run.missionId, - attempt, - step: stepForAttempt, - stepTitle, - stepKey, - content: buildWorkerLifecycleFailureMessage(attempt), - metadataSource: "worker_lifecycle_failed", - }); - } - recordMissionMetricSample(ctx, { - missionId: graph.run.missionId, - runId: attempt.runId, - attemptId: attempt.id, - metric: "retries", - value: step?.retryCount ?? 0, - unit: "count", - metadata: { - retryLimit: step?.retryLimit ?? null - } - }); - if (digest.tokens?.total != null) { - recordMissionMetricSample(ctx, { - missionId: graph.run.missionId, - runId: attempt.runId, - attemptId: attempt.id, - metric: "tokens", - value: digest.tokens.total, - unit: "tokens" - }); - } - if (digest.costUsd != null) { - recordMissionMetricSample(ctx, { - missionId: graph.run.missionId, - runId: attempt.runId, - attemptId: attempt.id, - metric: "cost", - value: digest.costUsd, - unit: "usd" - }); - } - if (step && step.status === "failed") { - try { - const stepMetadata = isRecord(step.metadata) ? step.metadata : {}; - const phaseKey = typeof stepMetadata.phaseKey === "string" ? stepMetadata.phaseKey.trim() : ""; - const stepType = typeof stepMetadata.stepType === "string" ? stepMetadata.stepType.trim() : ""; - const plannerContractFailure = attempt.errorClass === "planner_contract_violation"; - const retryExhausted = step.retryCount >= step.retryLimit; - let openedInterventionId: string | null = null; - if (plannerContractFailure || retryExhausted) { - const interventionSpec = plannerContractFailure - ? resolvePlannerPlanMissingIntervention({ - attempt, - step, - stepTitle: stepTitleForMessage(step), - }) - : resolveRetryExhaustedIntervention({ - attempt, - step, - stepTitle: stepTitleForMessage(step), - }); - if (!plannerContractFailure) { - deps.recordRuntimeEvent({ - runId: attempt.runId, - stepId: step.id, - attemptId: attempt.id, - sessionId: attempt.executorSessionId, - eventType: "retry_exhausted", - eventKey: `retry_exhausted:${step.id}`, - payload: { - retryCount: step.retryCount, - retryLimit: step.retryLimit, - lastError: attempt.errorMessage ?? "unknown" - } - }); - } - const intervention = ctx.missionService.addIntervention({ - missionId: graph.run.missionId, - interventionType: interventionSpec.interventionType, - title: interventionSpec.title, - body: interventionSpec.body, - requestedAction: interventionSpec.requestedAction, - metadata: { - runId: attempt.runId, - stepId: step.id, - stepKey: step.stepKey, - ...(phaseKey.length > 0 ? { phaseKey } : {}), - ...(stepType.length > 0 ? { stepType } : {}), - reasonCode: interventionSpec.reasonCode, - } - }); - deps.recordRuntimeEvent({ - runId: attempt.runId, - stepId: step.id, - attemptId: attempt.id, - sessionId: attempt.executorSessionId, - eventType: "intervention_opened", - eventKey: `intervention_opened:${intervention.id}`, - payload: { - interventionId: intervention.id, - interventionType: intervention.interventionType, - reason: interventionSpec.reasonCode, - ...(plannerContractFailure ? { expectedPlanPath: ".ade/plans/mission-plan.md" } : {}), - } - }); - openedInterventionId = intervention.id; - } - - const coordForDiag = ctx.coordinatorAgents.get(attempt.runId); - if (!coordForDiag?.isAlive && ctx.aiIntegrationService && ctx.projectRoot && !ctx.cancelingRuns.has(attempt.runId)) { - // AI failure diagnosis that DECIDES and ACTS, not just describes (one-shot fallback when no coordinator) - const diagConfig = resolveCallTypeConfig(ctx, graph.run.missionId, "coordinator"); - const diagModelConfig = resolveOrchestratorModelConfig(ctx, graph.run.missionId, "coordinator"); - void (async () => { - try { - const fullGraph = ctx.orchestratorService.getRunGraph({ runId: attempt.runId, timelineLimit: 5 }); - if ( - ctx.cancelingRuns.has(attempt.runId) - || fullGraph.run.status === "succeeded" - || fullGraph.run.status === "failed" - || fullGraph.run.status === "canceled" - ) { - return; - } - const succeededContext = fullGraph.steps - .filter((s) => s.status === "succeeded") - .slice(0, 5) - .map((s) => { - const lastAttempt = fullGraph.attempts - .filter((a) => a.stepId === s.id && a.status === "succeeded") - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; - return ` - ${s.stepKey}: ${lastAttempt?.resultEnvelope?.summary?.slice(0, 150) ?? "completed"}`; - }); - const blockedByThis = fullGraph.steps.filter((s) => - s.dependencyStepIds.includes(step.id) && s.status === "blocked" - ); - - const diagPrompt = [ - PM_SYSTEM_PREAMBLE, - "Your current role: FAILURE DIAGNOSTICIAN AND DECISION MAKER.", - "A step has exhausted all retries. You must DECIDE and ACT, not just describe the problem.", - "", - `Failed step: "${stepTitleForMessage(step)}" (key: ${step.stepKey})`, - `Retries: ${step.retryCount}/${step.retryLimit}`, - `Last error: ${attempt.errorMessage ?? "unknown"}`, - `Step instructions: ${(isRecord(step.metadata) && typeof step.metadata.instructions === "string") ? step.metadata.instructions.slice(0, 500) : "N/A"}`, - succeededContext.length > 0 ? `\nCompleted steps for context:\n${succeededContext.join("\n")}` : "", - blockedByThis.length > 0 ? `\nSteps BLOCKED by this failure: ${blockedByThis.map((s) => `"${s.title}"`).join(", ")}` : "", - "", - "Choose ONE action:", - "- skip: Non-critical step. Provide downstreamGuidance for blocked steps.", - "- workaround: Add a new step achieving the same goal differently. Provide workaroundStep details.", - "- retry: Provide revisedInstructions addressing the root cause.", - "- escalate: ONLY if you truly need human input. Explain exactly what's needed.", - "", - "BIAS TOWARD ACTION. 'workaround' or 'skip' is almost always better than 'escalate'." - ].join("\n"); - - const diagSchema = { - type: "object", - properties: { - rootCause: { type: "string" }, - category: { type: "string", enum: ["code", "environment", "design", "dependency", "unknown"] }, - recommendation: { type: "string", enum: ["retry", "skip", "workaround", "escalate"] }, - details: { type: "string" }, - revisedInstructions: { type: "string" }, - workaroundStep: { - type: "object", - properties: { - title: { type: "string" }, - instructions: { type: "string" }, - executorKind: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "manual"] } - } - }, - downstreamGuidance: { type: "string" } - }, - required: ["rootCause", "category", "recommendation", "details"] - }; - - const diagResult = await ctx.aiIntegrationService!.executeTask({ - feature: "orchestrator", - taskType: "review", - prompt: diagPrompt, - cwd: ctx.projectRoot!, - provider: diagConfig.provider, - model: modelConfigToServiceModel(diagModelConfig), - reasoningEffort: diagConfig.reasoningEffort, - jsonSchema: diagSchema, - oneShot: true, - timeoutMs: 45_000 - }); - - const latestGraph = ctx.orchestratorService.getRunGraph({ runId: attempt.runId, timelineLimit: 0 }); - if ( - ctx.cancelingRuns.has(attempt.runId) - || latestGraph.run.status === "succeeded" - || latestGraph.run.status === "failed" - || latestGraph.run.status === "canceled" - ) { - ctx.logger.info("ai_orchestrator.failure_diagnosis_discarded_terminal", { - runId: attempt.runId, - stepId: step.id, - runStatus: latestGraph.run.status, - }); - return; - } - - const diagParsed = isRecord(diagResult.structuredOutput) ? diagResult.structuredOutput : null; - const recommendation = String(diagParsed?.recommendation ?? "escalate"); - const rootCause = String(diagParsed?.rootCause ?? diagResult.text?.slice(0, 500) ?? "Unknown"); - - ctx.logger.info("ai_orchestrator.failure_diagnosis_completed", { - runId: attempt.runId, - stepId: step.id, - recommendation, - rootCause: rootCause.slice(0, 200) - }); - emitOrchestratorMessage( - ctx, - graph.run.missionId, - `[FAILURE DIAGNOSIS] "${stepTitleForMessage(step)}": ${rootCause.slice(0, 300)} → Action: ${recommendation}`, - step.stepKey, - null, - { appendChatMessage: deps.appendChatMessage } - ); - - // ACT on the diagnosis - if (recommendation === "skip") { - try { - ctx.orchestratorService.skipStep({ - runId: attempt.runId, - stepId: step.id, - reason: `AI diagnosis: ${rootCause.slice(0, 200)}` - }); - if (typeof diagParsed?.downstreamGuidance === "string" && blockedByThis.length > 0) { - for (const blocked of blockedByThis) { - deps.steerMission({ - missionId: graph.run.missionId, - directive: `Previous step "${stepTitleForMessage(step)}" was skipped. Guidance: ${diagParsed.downstreamGuidance}`, - priority: "instruction", - targetStepKey: blocked.stepKey - }); - } - } - emitOrchestratorMessage(ctx, graph.run.missionId, `AI auto-skipped failed step "${stepTitleForMessage(step)}"`, step.stepKey, null, { appendChatMessage: deps.appendChatMessage }); - void ctx.orchestratorService.startReadyAutopilotAttempts({ runId: attempt.runId, reason: "ai_diagnosis_skip" }).catch(() => {}); - } catch (skipErr) { - ctx.logger.debug("ai_orchestrator.diagnosis_skip_failed", { error: skipErr instanceof Error ? skipErr.message : String(skipErr) }); - } - } else if (recommendation === "workaround" && isRecord(diagParsed?.workaroundStep)) { - try { - const ws = diagParsed.workaroundStep; - const workaroundKey = `workaround-${step.stepKey}-${Date.now()}`; - ctx.orchestratorService.addSteps({ - runId: attempt.runId, - steps: [{ - stepKey: workaroundKey, - title: typeof ws.title === "string" ? ws.title : `Workaround for ${stepTitleForMessage(step)}`, - stepIndex: step.stepIndex + 1, - dependencyStepKeys: [], - executorKind: ( - typeof ws.executorKind === "string" - && ["claude", "codex", "cursor", "droid", "opencode", "manual"].includes(ws.executorKind) - ? ws.executorKind - : "opencode" - ) as OrchestratorExecutorKind, - retryLimit: 2, - metadata: { - instructions: typeof ws.instructions === "string" ? ws.instructions : "", - aiGenerated: true, - generationReason: `workaround for failed ${step.stepKey}: ${rootCause.slice(0, 200)}` - } - }] - }); - // Remap blocked steps to depend on workaround instead - for (const blocked of blockedByThis) { - try { - const currentDeps = blocked.dependencyStepIds - .map((depId) => fullGraph.steps.find((d) => d.id === depId)?.stepKey) - .filter((k): k is string => !!k); - const newDeps = currentDeps.map((k) => k === step.stepKey ? workaroundKey : k); - ctx.orchestratorService.updateStepDependencies({ - runId: attempt.runId, - stepId: blocked.id, - dependencyStepKeys: newDeps - }); - } catch { - // Best-effort dependency remap - } - } - emitOrchestratorMessage(ctx, graph.run.missionId, `AI added workaround for "${stepTitleForMessage(step)}"`, workaroundKey, null, { appendChatMessage: deps.appendChatMessage }); - void ctx.orchestratorService.startReadyAutopilotAttempts({ runId: attempt.runId, reason: "ai_diagnosis_workaround" }).catch(() => {}); - } catch (workaroundErr) { - ctx.logger.debug("ai_orchestrator.diagnosis_workaround_failed", { error: workaroundErr instanceof Error ? workaroundErr.message : String(workaroundErr) }); - } - } - // retry and escalate fall through to intervention handling below - } catch (diagError) { - ctx.logger.debug("ai_orchestrator.failure_diagnosis_failed", { - runId: attempt.runId, - stepId: step.id, - error: diagError instanceof Error ? diagError.message : String(diagError) - }); - } - })(); - - // Also attempt auto-resolution if configured - const runtimeProfile = ctx.runRuntimeProfiles.get(attempt.runId) ?? resolveActiveRuntimeProfile(ctx, graph.run.missionId); - if (openedInterventionId && runtimeProfile.evaluation.autoResolveInterventions) { - deps.handleInterventionWithAI({ - missionId: graph.run.missionId, - interventionId: openedInterventionId, - provider: resolveEvaluationProvider(attempt) - }).catch((error: unknown) => { - ctx.logger.debug("ai_orchestrator.auto_intervention_failed", { - runId: event.runId, - stepId: step.id, - error: error instanceof Error ? error.message : String(error) - }); - }); - } - } - } catch (interventionError) { - ctx.logger.debug("ai_orchestrator.create_intervention_failed", { - runId: event.runId, - stepId: step.id, - error: interventionError instanceof Error ? interventionError.message : String(interventionError) - }); - } - } - } - } catch (error) { - ctx.logger.debug("ai_orchestrator.worker_state_update_failed", { - attemptId: event.attemptId, - runId: event.runId, - error: error instanceof Error ? error.message : String(error) - }); - } -} diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 403c44f2c..e8d2c0faf 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -285,19 +285,11 @@ function repairLegacyPaths(paths: AdeLayoutPaths, actions: AdeSyncAction[]): voi const legacyFiles = fs.existsSync(paths.adeDir) ? fs.readdirSync(paths.adeDir) : []; for (const fileName of legacyFiles) { - if (fileName.startsWith("mission-state-") && fileName.endsWith(".json")) { - moveIfExists( - path.join(paths.adeDir, fileName), - path.join(paths.missionStateDir, fileName), - path.join("cache/mission-state", fileName), - actions - ); - } if (fileName.startsWith("coordinator-checkpoint-") && fileName.endsWith(".json")) { moveIfExists( path.join(paths.adeDir, fileName), - path.join(paths.missionStateDir, fileName), - path.join("cache/mission-state", fileName), + path.join(paths.orchestratorCacheDir, fileName), + path.join("cache/orchestrator", fileName), actions ); } @@ -354,7 +346,6 @@ export function initializeOrRepairAdeProject(projectRoot: string, options: Repai ensureDir(paths.chatSessionsDir, "cache/chat-sessions", actions); ensureDir(paths.chatTranscriptsDir, "transcripts/chat", actions); ensureDir(paths.orchestratorCacheDir, "cache/orchestrator", actions); - ensureDir(paths.missionStateDir, "cache/mission-state", actions); ensureDir(paths.packsDir, "artifacts/packs", actions); ensureDir(paths.logBundlesDir, "artifacts/log-bundles", actions); ensureDir(paths.githubSecretsDir, "secrets/github", actions); @@ -440,7 +431,6 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { args.ctoStateService?.getSnapshot?.(); args.workerAgentService?.listAgents?.(); const jsonlTargets = [ - path.join(repair.paths.historyDir, "missions.jsonl"), path.join(repair.paths.ctoDir, "sessions.jsonl"), ]; if (fs.existsSync(repair.paths.agentsDir)) { diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts index 262923102..f082d0a89 100644 --- a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -143,7 +143,6 @@ describe("initializeOrRepairAdeProject", () => { fs.writeFileSync(path.join(root, ".ade", "logs", "main.jsonl"), "{\"ok\":true}\n", "utf8"); fs.mkdirSync(path.join(root, ".ade", "chat-sessions"), { recursive: true }); fs.writeFileSync(path.join(root, ".ade", "chat-sessions", "session-1.json"), "{\"id\":\"session-1\"}\n", "utf8"); - fs.writeFileSync(path.join(root, ".ade", "mission-state-run-1.json"), "{\"runId\":\"run-1\"}\n", "utf8"); return root; } @@ -170,7 +169,6 @@ describe("initializeOrRepairAdeProject", () => { expect(fs.existsSync(layout.linearWorkflowsDir)).toBe(true); expect(fs.existsSync(path.join(layout.logsDir, "main.jsonl"))).toBe(true); expect(fs.existsSync(path.join(layout.chatSessionsDir, "session-1.json"))).toBe(true); - expect(fs.existsSync(path.join(layout.missionStateDir, "mission-state-run-1.json"))).toBe(true); expect(fs.existsSync(path.join(layout.adeDir, "logs"))).toBe(false); expect(fs.existsSync(path.join(layout.adeDir, "chat-sessions"))).toBe(false); }); diff --git a/apps/desktop/src/main/services/prs/prMergeQueue.test.ts b/apps/desktop/src/main/services/prs/prMergeQueue.test.ts index e7b469272..ce1ab0da5 100644 --- a/apps/desktop/src/main/services/prs/prMergeQueue.test.ts +++ b/apps/desktop/src/main/services/prs/prMergeQueue.test.ts @@ -293,7 +293,6 @@ describe("queueLandingService", () => { contextGaps: [], warnings: [], originSurface: "queue", - originMissionId: null, originRunId: null, originLabel: "Queue B", ptyId: null, diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index ecfffa9c4..d7d069f67 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -2895,7 +2895,6 @@ describe("prService.createIntegrationLane", () => { expect.objectContaining({ parentLaneId: BASE_LANE_ID, name: "integration/test", - laneRole: "integration", }), ); @@ -3100,55 +3099,6 @@ describe("prService.createIntegrationLane", () => { ).rejects.toThrow("git merge crashed"); }); - it("passes custom laneRole through to createChild", async () => { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: [SOURCE_LANE_A_ID], - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const laneService = makeIntegrationLaneService(); - const { service } = buildIntegrationService({ laneService }); - - await service.createIntegrationLane({ - sourceLaneIds: [SOURCE_LANE_A_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - laneRole: "result", - allowDirtyWorktree: true, - }); - - expect(laneService.createChild).toHaveBeenCalledWith( - expect.objectContaining({ laneRole: "result" }), - ); - }); - - it("defaults laneRole to 'integration' when not specified", async () => { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: [SOURCE_LANE_A_ID], - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const laneService = makeIntegrationLaneService(); - const { service } = buildIntegrationService({ laneService }); - - await service.createIntegrationLane({ - sourceLaneIds: [SOURCE_LANE_A_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - allowDirtyWorktree: true, - }); - - expect(laneService.createChild).toHaveBeenCalledWith( - expect.objectContaining({ laneRole: "integration" }), - ); - }); - it("throws when dirty worktrees are detected without allowDirtyWorktree", async () => { const dirtyLaneA = { ...sourceLaneA, diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 7629b373c..b4876470b 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -4610,8 +4610,6 @@ export function createPrService({ baseBranch: string; description?: string; allowDirtyWorktree?: boolean; - missionId?: string | null; - laneRole?: "mission_root" | "worker" | "integration" | "result" | null; }): Promise<{ integrationLane: LaneSummary; mergeResults: Array<{ laneId: string; success: boolean; error?: string }>; @@ -4647,8 +4645,6 @@ export function createPrService({ parentLaneId: preflight.baseLane.id, name: integrationLaneName, description: args.description?.trim() || `Integration lane for merging: ${sourceLaneNames.join(", ")}`, - missionId: args.missionId ?? null, - laneRole: args.laneRole ?? "integration", }); integrationLockToken = acquireLaneMutationLock({ lane: integrationLane, @@ -6114,7 +6110,6 @@ export function createPrService({ permissionMode: "guarded_edit", confidenceThreshold: null, originSurface: "manual", - originMissionId: null, originRunId: null, originLabel: null, }, @@ -7077,8 +7072,6 @@ export function createPrService({ baseBranch: string; description?: string; allowDirtyWorktree?: boolean; - missionId?: string | null; - laneRole?: "mission_root" | "worker" | "integration" | "result" | null; }): Promise<{ integrationLane: LaneSummary; mergeResults: Array<{ laneId: string; success: boolean; error?: string }>; diff --git a/apps/desktop/src/main/services/prs/queueLandingService.ts b/apps/desktop/src/main/services/prs/queueLandingService.ts index 77614e9fc..afb930131 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.ts +++ b/apps/desktop/src/main/services/prs/queueLandingService.ts @@ -63,7 +63,6 @@ const DEFAULT_QUEUE_CONFIG: QueueAutomationConfig = { }, }, originSurface: "manual", - originMissionId: null, originRunId: null, originLabel: null, // Legacy mirrors (retained for back-compat with iOS sync, RPC, older UI). @@ -346,7 +345,6 @@ export function createQueueLandingService({ permissionMode: args.permissionMode ?? prior.permissionMode ?? "guarded_edit", confidenceThreshold: args.confidenceThreshold ?? prior.confidenceThreshold ?? null, originSurface: args.originSurface ?? prior.originSurface ?? "manual", - originMissionId: args.originMissionId ?? prior.originMissionId ?? null, originRunId: args.originRunId ?? prior.originRunId ?? null, originLabel: args.originLabel ?? prior.originLabel ?? null, }; @@ -561,7 +559,6 @@ export function createQueueLandingService({ reasoningEffort: state.config.reasoningEffort, permissionMode: state.config.permissionMode, originSurface: state.config.originSurface, - originMissionId: state.config.originMissionId, originRunId: state.config.originRunId, originLabel: state.config.originLabel ?? `queue:${state.groupId}`, }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index cf6a955aa..fc38dd2bb 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -553,7 +553,6 @@ function isRemoteRuntimeEventCategory( value === "orchestrator" || value === "dag_mutation" || value === "runtime" || - value === "mission" || value === "pty" ); } diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts index 4e4b0545a..48d47869a 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts @@ -155,21 +155,6 @@ function createRegistry() { function createRuntime(projectRoot: string) { const operation = { operationId: "op-1" }; - const runGraph = { - run: { - id: "run-1", - missionId: "mission-1", - status: "running", - metadata: {}, - }, - steps: [], - attempts: [], - edges: [], - timeline: [], - contextSnapshots: [], - handoffs: [], - runtimeEvents: [], - }; return { projectRoot, workspaceRoot: projectRoot, @@ -199,10 +184,6 @@ function createRuntime(projectRoot: string) { list: vi.fn(() => []), }, eventBuffer: createEventBuffer(), - orchestratorService: { - listRuns: vi.fn(() => [runGraph.run]), - getRunGraph: vi.fn(() => runGraph), - }, dispose: vi.fn(), }; } @@ -292,7 +273,6 @@ describe("remote runtime offline RPC integration", () => { testRunId: null, chatSessionId: null, runId: null, - missionId: null, }, }); @@ -374,9 +354,9 @@ describe("remote runtime offline RPC integration", () => { const pool = new RemoteConnectionPool({ get: () => target, list: () => [target] } as unknown as RemoteTargetRegistry, "1.2.4"); await expect(pool.callActionForTarget(target, project.projectId, { - domain: "orchestrator_core", - action: "resumeRun", - args: { runId: "run-1" }, + domain: "operation", + action: "finish", + args: { operationId: "op-1", status: "cancelled" }, })).rejects.toThrow(/retry the action/i); expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); @@ -396,8 +376,8 @@ describe("remote runtime offline RPC integration", () => { }); runtime.eventBuffer.push({ timestamp: "2026-05-10T12:00:00.000Z", - category: "mission", - payload: { type: "mission_started", missionId: "mission-1", runId: "run-1" }, + category: "runtime", + payload: { type: "runtime_started", runId: "run-1" }, }); const firstRuntime = startRuntimeClient(handler); const secondRuntime = startRuntimeClient(handler); @@ -421,13 +401,13 @@ describe("remote runtime offline RPC integration", () => { cursor: 0, limit: 10, }); - expect(initialEvents.events.map((event) => event.payload.type)).toEqual(["mission_started"]); + expect(initialEvents.events.map((event) => event.payload.type)).toEqual(["runtime_started"]); expect(initialEvents.nextCursor).toBe(1); runtime.eventBuffer.push({ timestamp: "2026-05-10T12:00:01.000Z", - category: "mission", - payload: { type: "mission_resume_recovered", missionId: "mission-1", runId: "run-1" }, + category: "runtime", + payload: { type: "runtime_resume_recovered", runId: "run-1" }, }); firstRuntime.serverTransport.fail(new Error("channel closed")); @@ -437,25 +417,23 @@ describe("remote runtime offline RPC integration", () => { })).resolves.toMatchObject({ events: [{ id: 2, - category: "mission", - payload: { type: "mission_resume_recovered", missionId: "mission-1", runId: "run-1" }, + category: "runtime", + payload: { type: "runtime_resume_recovered", runId: "run-1" }, }], nextCursor: 2, hasMore: false, }); await expect(pool.callActionForTarget(target, project.projectId, { - domain: "orchestrator_core", - action: "getRunGraph", - args: { runId: "run-1", timelineLimit: 0 }, + domain: "operation", + action: "list", + args: {}, })).resolves.toMatchObject({ - domain: "orchestrator_core", - action: "getRunGraph", - result: { - run: { id: "run-1", missionId: "mission-1", status: "running" }, - }, + domain: "operation", + action: "list", + result: [], }); - expect(runtime.orchestratorService.getRunGraph).toHaveBeenCalledWith({ runId: "run-1", timelineLimit: 0 }); + expect(runtime.operationService.list).toHaveBeenCalledWith(undefined); expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts index 83ab5dfb7..127f868ee 100644 --- a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts +++ b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts @@ -40,41 +40,7 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { raw.run(` create table lanes( id text primary key, - project_id text not null, - mission_id text - ); - `); - raw.run(` - create table missions( - id text primary key, - project_id text not null, - title text, - prompt text, - status text, - outcome_summary text, - updated_at text - ); - `); - raw.run(` - create table orchestrator_worker_digests( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - step_id text not null, - attempt_id text not null, - lane_id text, - session_id text, - step_key text, - status text not null, - summary text not null, - files_changed_json text, - tests_run_json text, - warnings_json text, - tokens_json text, - cost_usd real, - suggested_next_actions_json text, - created_at text not null + project_id text not null ); `); raw.run(` @@ -172,49 +138,7 @@ function makeMaterialized(changedPaths: string[]) { describe("reviewContextBuilder", () => { it("builds a bounded compact packet from ADE-native provenance, rules, and validation signals", async () => { const { db } = createInMemoryAdeDb(); - db.run("insert into lanes(id, project_id, mission_id) values (?, ?, ?)", ["lane-review", "project-1", "mission-1"]); - db.run( - "insert into missions(id, project_id, title, prompt, status, outcome_summary, updated_at) values (?, ?, ?, ?, ?, ?, ?)", - [ - "mission-1", - "project-1", - "Keep preload and renderer aligned", - "This is a very long mission prompt that should be clipped in the compact provenance packet because raw prompt bloat is not review-safe and should never be copied wholesale into prompts or artifacts.", - "running", - "Workers are still converging on the bridge rollout.", - "2026-04-06T09:59:00.000Z", - ], - ); - for (let index = 0; index < 4; index += 1) { - db.run( - ` - insert into orchestrator_worker_digests( - id, project_id, mission_id, run_id, step_id, attempt_id, lane_id, session_id, step_key, status, - summary, files_changed_json, tests_run_json, warnings_json, tokens_json, cost_usd, suggested_next_actions_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - `digest-${index}`, - "project-1", - "mission-1", - "orchestrator-run-1", - `step-${index}`, - `attempt-${index}`, - "lane-review", - `session-${index}`, - `step-${index}`, - index === 0 ? "failed" : "succeeded", - `Worker digest ${index} repeated text ${"x".repeat(80)}`, - JSON.stringify(["apps/desktop/src/preload/reviewBridge.ts"]), - JSON.stringify({ passed: 0, failed: 1, skipped: 0, summary: "bridge unit failed" }), - JSON.stringify(["warning one", "warning two"]), - null, - null, - JSON.stringify(["fix the bridge"]), - `2026-04-06T10:0${index}:00.000Z`, - ], - ); - } + db.run("insert into lanes(id, project_id) values (?, ?)", ["lane-review", "project-1"]); db.run( ` insert into pull_requests( @@ -258,7 +182,6 @@ describe("reviewContextBuilder", () => { getStateSnapshot: vi.fn().mockReturnValue({ laneId: "lane-review", agentSummary: { summary: "Recent ADE chat distilled the bridge rollout intent." }, - missionSummary: { summary: "Finish the preload bridge rollout cleanly." }, updatedAt: "2026-04-06T09:58:00.000Z", }), } as any, @@ -411,11 +334,9 @@ describe("reviewContextBuilder", () => { ]) as any, }); - expect(packet.provenance.payload.workerDigests).toHaveLength(3); expect(packet.provenance.payload.sessionDeltas).toHaveLength(3); expect(packet.validation.payload.issueInventory).toHaveLength(5); expect(packet.validation.payload.signals.length).toBeLessThanOrEqual(5); - expect(packet.provenance.payload.missions[0]?.intentSummary?.length ?? 0).toBeLessThanOrEqual(220); expect(packet.validation.payload.testRuns[0]?.logExcerpt?.length ?? 0).toBeLessThanOrEqual(220); expect(packet.validation.prompt).not.toContain("noise noise noise noise noise noise"); expect(packet.rules.metadata.matchedRuleIds).toContain("preload-bridge"); @@ -424,7 +345,7 @@ describe("reviewContextBuilder", () => { it("emits late-stage signals for validation failures, reviewer feedback, and prior review overlap", async () => { const { db } = createInMemoryAdeDb(); - db.run("insert into lanes(id, project_id, mission_id) values (?, ?, ?)", ["lane-review", "project-1", null]); + db.run("insert into lanes(id, project_id) values (?, ?)", ["lane-review", "project-1"]); db.run( ` insert into pull_requests( diff --git a/apps/desktop/src/main/services/review/reviewContextBuilder.ts b/apps/desktop/src/main/services/review/reviewContextBuilder.ts index 524ded6df..071f277f1 100644 --- a/apps/desktop/src/main/services/review/reviewContextBuilder.ts +++ b/apps/desktop/src/main/services/review/reviewContextBuilder.ts @@ -9,7 +9,6 @@ import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createPrService } from "../prs/prService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import { parseWorkerDigestRow } from "../orchestrator/workerTracking"; import type { createSessionDeltaService } from "../sessions/sessionDeltaService"; import type { AdeDb } from "../state/kvDb"; import type { createTestService } from "../tests/testService"; @@ -20,8 +19,6 @@ import { type MatchedReviewRuleOverlay, } from "./reviewRuleRegistry"; -const MISSION_LIMIT = 1; -const WORKER_DIGEST_LIMIT = 3; const SESSION_DELTA_LIMIT = 3; const PRIOR_REVIEW_LIMIT = 2; const VALIDATION_SIGNAL_LIMIT = 5; @@ -40,15 +37,6 @@ type LinkedPrRow = { updated_at: string; }; -type MissionRow = { - mission_id: string | null; - title: string | null; - prompt: string | null; - status: string | null; - outcome_summary: string | null; - updated_at: string | null; -}; - type PriorReviewRow = { id: string; status: string; @@ -58,51 +46,12 @@ type PriorReviewRow = { publication_count: number; }; -type WorkerDigestRow = { - id: string; - mission_id: string; - run_id: string; - step_id: string; - attempt_id: string; - lane_id: string | null; - session_id: string | null; - step_key: string | null; - status: string; - summary: string; - files_changed_json: string | null; - tests_run_json: string | null; - warnings_json: string | null; - tokens_json: string | null; - cost_usd: number | null; - suggested_next_actions_json: string | null; - created_at: string; -}; - export type ReviewContextProvenancePayload = { changedPaths: string[]; laneSnapshot: { updatedAt: string | null; agentSummary: string | null; - missionSummary: string | null; } | null; - missions: Array<{ - id: string; - title: string; - status: string | null; - outcomeSummary: string | null; - intentSummary: string | null; - updatedAt: string | null; - }>; - workerDigests: Array<{ - id: string; - stepKey: string | null; - status: string; - summary: string; - filesChanged: string[]; - testsSummary: string | null; - warnings: string[]; - createdAt: string; - }>; sessionDeltas: Array<{ sessionId: string; startedAt: string; @@ -250,7 +199,6 @@ function summarizeRecord(record: Record<string, unknown> | null): string | null "statusSummary", "latestMessage", "intent", - "mission", ]; const directValues = directKeys .map((key) => record[key]) @@ -338,55 +286,6 @@ export function createReviewContextBuilder({ ); } - function getMissionRow(laneId: string): MissionRow | null { - return db.get<MissionRow>( - ` - select - l.mission_id as mission_id, - m.title as title, - m.prompt as prompt, - m.status as status, - m.outcome_summary as outcome_summary, - m.updated_at as updated_at - from lanes l - left join missions m on m.id = l.mission_id - where l.id = ? - and l.project_id = ? - limit 1 - `, - [laneId, projectId], - ); - } - - function listWorkerDigests(missionId: string | null, laneId: string): ReviewContextProvenancePayload["workerDigests"] { - if (!missionId?.trim()) return []; - const rows = db.all<WorkerDigestRow>( - ` - select * - from orchestrator_worker_digests - where mission_id = ? - and project_id = ? - and (lane_id = ? or lane_id is null) - order by created_at desc - limit ? - `, - [missionId, projectId, laneId, WORKER_DIGEST_LIMIT], - ); - return rows.map((row) => { - const digest = parseWorkerDigestRow(row); - return { - id: digest.id, - stepKey: digest.stepKey, - status: digest.status, - summary: clipText(digest.summary) ?? "No summary", - filesChanged: digest.filesChanged.slice(0, 4), - testsSummary: clipText(digest.testsRun?.summary ?? null), - warnings: compactList(digest.warnings, 2), - createdAt: digest.createdAt, - }; - }); - } - function listPriorReviews(runId: string, laneId: string, changedPaths: string[]): ReviewContextProvenancePayload["priorReviews"] { const rows = db.all<PriorReviewRow>( ` @@ -588,8 +487,6 @@ export function createReviewContextBuilder({ function buildProvenancePayload(args: { materialized: ReviewMaterializedTarget; laneSnapshot: ReturnType<Pick<ReturnType<typeof createLaneService>, "getStateSnapshot">["getStateSnapshot"]>; - missionRow: MissionRow | null; - workerDigests: ReviewContextProvenancePayload["workerDigests"]; sessionDeltas: SessionDeltaSummary[]; priorReviews: ReviewContextProvenancePayload["priorReviews"]; validation: ReviewContextValidationPayload; @@ -604,14 +501,6 @@ export function createReviewContextBuilder({ failureLines: compactList(delta.failureLines, 3), computedAt: delta.computedAt, })); - const missions = args.missionRow?.mission_id && MISSION_LIMIT > 0 ? [{ - id: args.missionRow.mission_id, - title: clipText(args.missionRow.title, 120) ?? args.missionRow.mission_id, - status: args.missionRow.status, - outcomeSummary: clipText(args.missionRow.outcome_summary), - intentSummary: clipText(args.missionRow.prompt), - updatedAt: args.missionRow.updated_at, - }] : []; const lateStageSignals: ReviewContextProvenancePayload["lateStageSignals"] = []; for (const delta of normalizedSessionDeltas) { const overlappingPaths = overlapsChangedPaths(delta.touchedFiles, changedPaths); @@ -653,10 +542,7 @@ export function createReviewContextBuilder({ laneSnapshot: args.laneSnapshot ? { updatedAt: args.laneSnapshot.updatedAt, agentSummary: clipText(summarizeRecord(args.laneSnapshot.agentSummary)), - missionSummary: clipText(summarizeRecord(args.laneSnapshot.missionSummary)), } : null, - missions, - workerDigests: args.workerDigests, sessionDeltas: normalizedSessionDeltas, priorReviews: args.priorReviews, lateStageSignals: lateStageSignals.slice(0, VALIDATION_SIGNAL_LIMIT), @@ -668,15 +554,6 @@ export function createReviewContextBuilder({ if (payload.laneSnapshot?.agentSummary) { lines.push(`- Lane agent summary: ${payload.laneSnapshot.agentSummary}`); } - if (payload.laneSnapshot?.missionSummary) { - lines.push(`- Lane mission summary: ${payload.laneSnapshot.missionSummary}`); - } - for (const mission of payload.missions) { - lines.push(`- Mission: ${mission.title}${mission.status ? ` [${mission.status}]` : ""}${mission.outcomeSummary ? ` — ${mission.outcomeSummary}` : mission.intentSummary ? ` — ${mission.intentSummary}` : ""}`); - } - for (const digest of payload.workerDigests) { - lines.push(`- Worker digest: ${digest.stepKey ?? "worker"} ${digest.status} — ${digest.summary}`); - } for (const delta of payload.sessionDeltas) { if (delta.failureLines.length > 0) { lines.push(`- Session delta: ${delta.failureLines.join(" | ")}`); @@ -721,8 +598,6 @@ export function createReviewContextBuilder({ function buildProvenanceSummary(payload: ReviewContextProvenancePayload): string { const parts = [ - payload.missions.length > 0 ? `${payload.missions.length} mission` : null, - payload.workerDigests.length > 0 ? `${payload.workerDigests.length} worker digest${payload.workerDigests.length === 1 ? "" : "s"}` : null, payload.sessionDeltas.length > 0 ? `${payload.sessionDeltas.length} session delta${payload.sessionDeltas.length === 1 ? "" : "s"}` : null, payload.priorReviews.length > 0 ? `${payload.priorReviews.length} prior review${payload.priorReviews.length === 1 ? "" : "s"}` : null, payload.lateStageSignals.length > 0 ? `${payload.lateStageSignals.length} late-stage signal${payload.lateStageSignals.length === 1 ? "" : "s"}` : null, @@ -752,9 +627,7 @@ export function createReviewContextBuilder({ }): Promise<ReviewContextPacket> { const changedPaths = args.materialized.changedFiles.map((file) => file.filePath); const laneSnapshot = laneService.getStateSnapshot(args.run.laneId); - const missionRow = getMissionRow(args.run.laneId); const sessionDeltas = sessionDeltaService.listRecentLaneSessionDeltas(args.run.laneId, SESSION_DELTA_LIMIT); - const workerDigests = listWorkerDigests(missionRow?.mission_id ?? null, args.run.laneId); const priorReviews = listPriorReviews(args.run.id, args.run.laneId, changedPaths); const linkedPr = getLinkedPrRow(args.run); const matchedRules = matchReviewRuleOverlays(changedPaths); @@ -775,8 +648,6 @@ export function createReviewContextBuilder({ const provenancePayload = buildProvenancePayload({ materialized: args.materialized, laneSnapshot, - missionRow, - workerDigests, sessionDeltas, priorReviews, validation: validationPayload, @@ -804,13 +675,9 @@ export function createReviewContextBuilder({ metadata: { summary: buildProvenanceSummary(provenancePayload), provenanceCount: - provenancePayload.missions.length - + provenancePayload.workerDigests.length - + provenancePayload.sessionDeltas.length + provenancePayload.sessionDeltas.length + provenancePayload.priorReviews.length + provenancePayload.lateStageSignals.length, - missionCount: provenancePayload.missions.length, - workerDigestCount: provenancePayload.workerDigests.length, sessionDeltaCount: provenancePayload.sessionDeltas.length, priorReviewCount: provenancePayload.priorReviews.length, lateStageSignalCount: provenancePayload.lateStageSignals.length, diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index 2f78c1759..4a6061a48 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -334,37 +334,14 @@ function makeContextPacket(overrides: Partial<Record<string, any>> = {}) { return { matchedRuleOverlays: [], provenance: { - summary: "1 mission, 1 late-stage signal", - prompt: "- Mission: keep renderer and preload behavior aligned.\n- Late-stage signal: recent validation failed on src/review.ts", + summary: "1 late-stage signal", + prompt: "- Lane agent summary: keep renderer and preload behavior aligned.\n- Late-stage signal: recent validation failed on src/review.ts", payload: { changedPaths: ["src/review.ts"], laneSnapshot: { updatedAt: "2026-04-06T09:58:00.000Z", agentSummary: "Recent ADE chat focused on restoring the review fallback path.", - missionSummary: "Finish the review engine rollout cleanly.", }, - missions: [ - { - id: "mission-1", - title: "Restore review fallback behavior", - status: "running", - outcomeSummary: null, - intentSummary: "Keep fallback behavior and ship the review engine safely.", - updatedAt: "2026-04-06T09:59:00.000Z", - }, - ], - workerDigests: [ - { - id: "digest-1", - stepKey: "implement", - status: "succeeded", - summary: "Updated the review engine behavior.", - filesChanged: ["src/review.ts"], - testsSummary: "1 failed", - warnings: [], - createdAt: "2026-04-06T10:00:00.000Z", - }, - ], sessionDeltas: [ { sessionId: "session-delta-1", @@ -398,10 +375,8 @@ function makeContextPacket(overrides: Partial<Record<string, any>> = {}) { ], }, metadata: { - summary: "1 mission, 1 worker digest, 1 session delta, 1 late-stage signal", - provenanceCount: 4, - missionCount: 1, - workerDigestCount: 1, + summary: "1 session delta, 1 late-stage signal", + provenanceCount: 3, sessionDeltaCount: 1, priorReviewCount: 1, lateStageSignalCount: 1, @@ -1037,7 +1012,7 @@ describe("reviewService", () => { const harnessArtifact = detail?.artifacts.find((artifact) => artifact.artifactType === "prompt"); expect(harnessArtifact?.metadata).toMatchObject({ matchedRuleCount: 1, - provenanceCount: 4, + provenanceCount: 3, validationSignalCount: 2, matchedRuleIds: ["renderer-surface"], }); @@ -1078,8 +1053,6 @@ describe("reviewService", () => { payload: { changedPaths: ["src/review.ts"], laneSnapshot: null, - missions: [], - workerDigests: [], sessionDeltas: [], priorReviews: [], lateStageSignals: [], @@ -1087,8 +1060,6 @@ describe("reviewService", () => { metadata: { summary: "No ADE provenance context", provenanceCount: 0, - missionCount: 0, - workerDigestCount: 0, sessionDeltaCount: 0, priorReviewCount: 0, lateStageSignalCount: 0, diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index a3776acc2..511a49adb 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -1179,9 +1179,7 @@ function inferFindingClass(args: { ); if (hasStrictMissingRollout) return "incomplete_rollout"; const hasIntentContext = Boolean( - args.context.provenance.payload.missions.length > 0 - || args.context.provenance.payload.laneSnapshot?.agentSummary - || args.context.provenance.payload.laneSnapshot?.missionSummary, + args.context.provenance.payload.laneSnapshot?.agentSummary, ); const wordingSuggestsDrift = args.group.some((candidate) => /\b(expected|intent|should|instead|missing|omits?|drift)\b/i.test(`${candidate.title} ${candidate.body}`), diff --git a/apps/desktop/src/main/services/state/kvDb.migrations.test.ts b/apps/desktop/src/main/services/state/kvDb.migrations.test.ts index 8d20ba800..7839cae52 100644 --- a/apps/desktop/src/main/services/state/kvDb.migrations.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.migrations.test.ts @@ -53,363 +53,6 @@ function expectIndexes(db: Awaited<ReturnType<typeof openKvDb>>, indexes: readon } } -describe("kvDb migrations - orchestrator schema bootstrap", () => { - it("creates Phase 1.5 context hardening tables and indexes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-orchestrator-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - try { - - const expectedTables = [ - "orchestrator_runs", - "orchestrator_steps", - "orchestrator_attempts", - "orchestrator_attempt_runtime", - "orchestrator_runtime_events", - "orchestrator_claims", - "orchestrator_context_snapshots", - "mission_step_handoffs", - "orchestrator_timeline_events", - "orchestrator_gate_reports", - "orchestrator_reflections", - "orchestrator_retrospectives", - "orchestrator_retrospective_trends", - "orchestrator_reflection_pattern_stats", - "orchestrator_reflection_pattern_sources", - "orchestrator_lane_decisions", - "orchestrator_ai_decisions", - ]; - - expectTables(db, expectedTables); - - expect(listColumnNames(db, "orchestrator_runs")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "status", - "context_profile", - "scheduler_state", - "runtime_cursor_json", - "last_error", - "metadata_json", - "created_at", - "updated_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_steps")).toEqual( - expect.arrayContaining([ - "id", - "run_id", - "project_id", - "mission_step_id", - "step_key", - "status", - "join_policy", - "dependency_step_ids_json", - "retry_limit", - "retry_count", - ]), - ); - - expect(listColumnNames(db, "orchestrator_attempts")).toEqual( - expect.arrayContaining([ - "id", - "run_id", - "step_id", - "project_id", - "attempt_number", - "status", - "executor_kind", - "tracked_session_enforced", - "context_profile", - "context_snapshot_id", - "error_class", - "result_envelope_json", - ]), - ); - - expect(listColumnNames(db, "orchestrator_attempt_runtime")).toEqual( - expect.arrayContaining([ - "attempt_id", - "session_id", - "runtime_state", - "last_signal_at", - "last_output_preview", - "last_preview_digest", - "digest_since_ms", - "repeat_count", - "last_waiting_intervention_at_ms", - "last_event_heartbeat_at_ms", - "last_waiting_notified_at_ms", - "updated_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_runtime_events")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "session_id", - "event_type", - "event_key", - "occurred_at", - "payload_json", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_claims")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "owner_id", - "scope_kind", - "scope_value", - "state", - "heartbeat_at", - "expires_at", - "policy_json", - ]), - ); - - expect(listColumnNames(db, "orchestrator_context_snapshots")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "snapshot_type", - "context_profile", - "cursor_json", - "created_at", - ]), - ); - - expect(listColumnNames(db, "mission_step_handoffs")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "mission_step_id", - "run_id", - "step_id", - "attempt_id", - "handoff_type", - "producer", - "payload_json", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_timeline_events")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "claim_id", - "event_type", - "reason", - "detail_json", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_gate_reports")).toEqual( - expect.arrayContaining(["id", "project_id", "generated_at", "report_json"]), - ); - - expect(listColumnNames(db, "orchestrator_reflections")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "agent_role", - "phase", - "signal_type", - "observation", - "recommendation", - "context", - "occurred_at", - "created_at", - "schema_version", - ]), - ); - - expect(listColumnNames(db, "orchestrator_retrospectives")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "generated_at", - "final_status", - "payload_json", - "schema_version", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_retrospective_trends")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "retrospective_id", - "source_mission_id", - "source_run_id", - "source_retrospective_id", - "pain_point_key", - "pain_point_label", - "status", - "previous_pain_score", - "current_pain_score", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_reflection_pattern_stats")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "pattern_key", - "pattern_label", - "occurrence_count", - "first_seen_retrospective_id", - "first_seen_run_id", - "last_seen_retrospective_id", - "last_seen_run_id", - "created_at", - "updated_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_reflection_pattern_sources")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "pattern_stat_id", - "retrospective_id", - "mission_id", - "run_id", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_lane_decisions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "step_id", - "step_key", - "lane_id", - "decision_type", - "validator_outcome", - "rule_hits_json", - "rationale", - "metadata_json", - "created_at", - ]), - ); - - expect(listColumnNames(db, "orchestrator_ai_decisions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "step_id", - "attempt_id", - "call_type", - "provider", - "model", - "timeout_cap_ms", - "decision_json", - "action_trace_json", - "validation_json", - "rationale", - "fallback_used", - "failure_reason", - "duration_ms", - "prompt_tokens", - "completion_tokens", - "created_at", - ]), - ); - - const expectedIndexes = [ - "idx_orchestrator_runs_project_status", - "idx_orchestrator_runs_mission", - "idx_orchestrator_runs_project_updated", - "idx_orchestrator_steps_run_status", - "idx_orchestrator_steps_project_status", - "idx_orchestrator_steps_run_order", - "idx_orchestrator_attempts_run_status", - "idx_orchestrator_attempts_step_status", - "idx_orchestrator_attempts_project_created", - "idx_orchestrator_attempt_runtime_session", - "idx_orchestrator_attempt_runtime_updated", - "idx_orchestrator_runtime_events_run_occurred", - "idx_orchestrator_runtime_events_attempt_occurred", - "idx_orchestrator_runtime_events_session_occurred", - "idx_orchestrator_runtime_events_project_key", - "idx_orchestrator_claims_run_state", - "idx_orchestrator_claims_scope_state", - "idx_orchestrator_claims_expires", - "idx_orchestrator_claims_active_scope", - "idx_orchestrator_context_snapshots_run_created", - "idx_orchestrator_context_snapshots_attempt", - "idx_mission_step_handoffs_mission_created", - "idx_mission_step_handoffs_step_created", - "idx_mission_step_handoffs_attempt", - "idx_orchestrator_timeline_run_created", - "idx_orchestrator_timeline_attempt", - "idx_orchestrator_timeline_project_created", - "idx_orchestrator_gate_reports_project_generated", - "idx_orchestrator_reflections_run_occurred", - "idx_orchestrator_reflections_mission", - "idx_orchestrator_retrospectives_mission_generated", - "idx_orchestrator_retrospective_trends_mission_created", - "idx_orchestrator_retrospective_trends_run_created", - "idx_orchestrator_reflection_pattern_stats_count", - "idx_orchestrator_reflection_pattern_sources_pattern", - "idx_orchestrator_reflection_pattern_sources_mission", - "idx_orchestrator_lane_decisions_mission_created", - "idx_orchestrator_lane_decisions_run_created", - "idx_orchestrator_lane_decisions_step_created", - "idx_orchestrator_lane_decisions_lane_created", - "idx_orchestrator_ai_decisions_mission_created", - "idx_orchestrator_ai_decisions_run_created", - "idx_orchestrator_ai_decisions_step_created", - "idx_orchestrator_ai_decisions_project_category_created", - "idx_orchestrator_ai_decisions_created", - ]; - - expectIndexes(db, expectedIndexes); - - const activeScopeSql = db.get<{ sql: string | null }>( - "select sql from sqlite_master where type = 'index' and name = 'idx_orchestrator_claims_active_scope' limit 1", - ); - expect((activeScopeSql?.sql ?? "").toLowerCase()).toContain("where state = 'active'"); - } finally { - db.close(); - } - }); -}); describe("kvDb migrations - lane worktree lock schema", () => { it("repairs legacy lock tables before lane lock upserts run", async () => { @@ -603,84 +246,6 @@ describe("kvDb migrations - pipeline settings", () => { }); }); -describe("kvDb migrations - mission schema", () => { - it("creates mission tables and key indexes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-missions-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - try { - - const expectedTables = [ - "missions", - "mission_steps", - "mission_events", - "mission_artifacts", - "mission_interventions", - ]; - - expectTables(db, expectedTables); - - expect(listColumnNames(db, "missions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "lane_id", - "title", - "prompt", - "status", - "priority", - "execution_mode", - "target_machine_id", - "outcome_summary", - "last_error", - "metadata_json", - "created_at", - "updated_at", - "started_at", - "completed_at", - ]), - ); - - expect(listColumnNames(db, "mission_steps")).toEqual( - expect.arrayContaining(["mission_id", "step_index", "status", "started_at", "completed_at"]), - ); - - expect(listColumnNames(db, "lanes")).toEqual(expect.arrayContaining(["folder"])); - - expect(listColumnNames(db, "pr_groups")).toEqual( - expect.arrayContaining(["name", "auto_rebase", "ci_gating", "target_branch"]), - ); - - expect(listColumnNames(db, "integration_proposals")).toEqual( - expect.arrayContaining([ - "title", - "body", - "draft", - "integration_lane_name", - "status", - "integration_lane_id", - "preferred_integration_lane_id", - "merge_into_head_sha", - "resolution_state_json", - "pairwise_results_json", - "lane_summaries_json", - ]), - ); - - const expectedIndexes = [ - "idx_missions_project_updated", - "idx_mission_steps_mission_index", - "idx_mission_events_mission_created", - "idx_mission_artifacts_mission_created", - "idx_mission_interventions_mission_status", - ]; - - expectIndexes(db, expectedIndexes); - } finally { - db.close(); - } - }); -}); describe("kvDb migrations - worker agent schema", () => { it("creates W2 worker tables and indexes", async () => { diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index 95bf139cc..a0069b8ee 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -84,15 +84,15 @@ function insertProjectGraph(db: Awaited<ReturnType<typeof openKvDb>>) { ); db.run( `insert into lane_state_snapshots( - lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["lane-primary", 0, 0, 0, 0, 0, null, null, now], + lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-primary", 0, 0, 0, 0, 0, null, now], ); db.run( `insert into lane_state_snapshots( - lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["lane-child", 1, 0, 1, 0, 0, null, null, "2026-03-17T00:05:00.000Z"], + lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-child", 1, 0, 1, 0, 0, null, "2026-03-17T00:05:00.000Z"], ); } diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index b18c2631c..9e42a8947 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -268,7 +268,7 @@ function retrofitLegacyPrimaryKeyNotNullSchema(db: DatabaseSyncType): boolean { /** * Desired foreign key constraints with ON DELETE actions. * - * Keyed by `"table:column"`. `references` is the target (e.g. `"missions(id)"`), + * Keyed by `"table:column"`. `references` is the target (e.g. `"pull_requests(id)"`), * `action` is the ON DELETE clause (e.g. `"on delete cascade"`). * * When a database was created before these clauses were added to the CREATE @@ -277,31 +277,6 @@ function retrofitLegacyPrimaryKeyNotNullSchema(db: DatabaseSyncType): boolean { * referential actions. */ const FK_CONSTRAINTS: Record<string, { references: string; action: string }> = { - // lanes - "lanes:mission_id": { references: "missions(id)", action: "on delete set null" }, - // missions - "missions:mission_lane_id": { references: "lanes(id)", action: "on delete set null" }, - "missions:result_lane_id": { references: "lanes(id)", action: "on delete set null" }, - // mission child tables - "mission_steps:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "mission_events:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "mission_artifacts:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "mission_interventions:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "mission_phase_overrides:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "mission_step_handoffs:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "mission_metrics_config:mission_id": { references: "missions(id)", action: "on delete cascade" }, - // orchestrator tables - "orchestrator_runs:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_chat_threads:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_chat_messages:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_worker_digests:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_artifacts:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_context_checkpoints:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_worker_checkpoints:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_lane_decisions:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_ai_decisions:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_metrics_samples:mission_id": { references: "missions(id)", action: "on delete cascade" }, - "orchestrator_team_members:mission_id": { references: "missions(id)", action: "on delete cascade" }, // PR convergence loop tables "pr_issue_inventory:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, "pr_pipeline_settings:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, @@ -368,7 +343,7 @@ function retrofitForeignKeyCascadeActions(db: DatabaseSyncType, crsqliteEnabled: for (const { column, references, action } of desired) { // Match a foreign key constraint line for this column, e.g.: - // foreign key(mission_id) references missions(id) + // foreign key(pr_id) references pull_requests(id) // Optionally already carrying an ON DELETE clause. const fkPattern = new RegExp( `(foreign\\s+key\\s*\\(\\s*${escapeRegExp(column)}\\s*\\)\\s+references\\s+\\w+\\s*\\([^)]+\\))` + @@ -995,25 +970,18 @@ function migrate(db: MigrationDb) { icon text, tags_json text, folder text, - mission_id text, - lane_role text, runtime_placement text not null default 'local', status text not null, created_at text not null, archived_at text, foreign key(project_id) references projects(id), - foreign key(parent_lane_id) references lanes(id), - foreign key(mission_id) references missions(id) on delete set null + foreign key(parent_lane_id) references lanes(id) ) `); - try { db.run("alter table lanes add column mission_id text"); } catch {} - try { db.run("alter table lanes add column lane_role text"); } catch {} try { db.run("alter table lanes add column runtime_placement text not null default 'local'"); } catch {} db.run("create index if not exists idx_lanes_project_id on lanes(project_id)"); db.run("create index if not exists idx_lanes_project_type on lanes(project_id, lane_type)"); db.run("create index if not exists idx_lanes_project_parent on lanes(project_id, parent_lane_id)"); - db.run("create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id)"); - db.run("create index if not exists idx_lanes_project_role on lanes(project_id, lane_role)"); db.run(` create table if not exists lane_linear_issues ( @@ -1186,7 +1154,6 @@ function migrate(db: MigrationDb) { remote_behind integer not null default -1, rebase_in_progress integer not null default 0, agent_summary_json text, - mission_summary_json text, updated_at text not null, foreign key(lane_id) references lanes(id) ) @@ -1902,89 +1869,6 @@ function migrate(db: MigrationDb) { `); db.run("create index if not exists idx_rebase_deferred_project on rebase_deferred(project_id)"); - // Phase 1 missions model foundation. - db.run(` - create table if not exists missions ( - id text primary key, - project_id text not null, - lane_id text, - mission_lane_id text, - result_lane_id text, - title text not null, - prompt text not null, - status text not null, - priority text not null default 'normal', - execution_mode text not null default 'local', - target_machine_id text, - queue_claim_token text, - queue_claimed_at text, - outcome_summary text, - last_error text, - metadata_json text, - created_at text not null, - updated_at text not null, - started_at text, - completed_at text, - archived_at text, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id), - foreign key(mission_lane_id) references lanes(id) on delete set null, - foreign key(result_lane_id) references lanes(id) on delete set null - ) - `); - try { db.run("alter table missions add column mission_lane_id text"); } catch {} - try { db.run("alter table missions add column result_lane_id text"); } catch {} - try { db.run("alter table missions add column queue_claim_token text"); } catch {} - try { db.run("alter table missions add column queue_claimed_at text"); } catch {} - try { db.run("alter table missions add column archived_at text"); } catch {} - db.run("create index if not exists idx_missions_project_updated on missions(project_id, updated_at)"); - db.run("create index if not exists idx_missions_project_status on missions(project_id, status)"); - db.run("create index if not exists idx_missions_project_lane on missions(project_id, lane_id)"); - db.run("create index if not exists idx_missions_project_mission_lane on missions(project_id, mission_lane_id)"); - db.run("create index if not exists idx_missions_project_result_lane on missions(project_id, result_lane_id)"); - db.run("drop index if exists idx_missions_queue_claim_token"); - db.run("create index if not exists idx_missions_project_queue_claim on missions(project_id, queue_claim_token)"); - - db.run(` - create table if not exists mission_steps ( - id text primary key, - mission_id text not null, - project_id text not null, - step_index integer not null, - title text not null, - detail text, - kind text not null default 'manual', - lane_id text, - status text not null, - metadata_json text, - created_at text not null, - updated_at text not null, - started_at text, - completed_at text, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id) - ) - `); - db.run("create index if not exists idx_mission_steps_mission_index on mission_steps(mission_id, step_index)"); - db.run("create index if not exists idx_mission_steps_project_status on mission_steps(project_id, status)"); - - db.run(` - create table if not exists mission_events ( - id text primary key, - mission_id text not null, - project_id text not null, - event_type text not null, - actor text not null, - summary text not null, - payload_json text, - created_at text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id) - ) - `); - db.run("create index if not exists idx_mission_events_mission_created on mission_events(mission_id, created_at)"); - db.run("create index if not exists idx_mission_events_project_created on mission_events(project_id, created_at)"); db.run(` create table if not exists computer_use_artifacts ( @@ -2025,615 +1909,6 @@ function migrate(db: MigrationDb) { db.run("create index if not exists idx_computer_use_artifact_links_owner on computer_use_artifact_links(project_id, owner_kind, owner_id, created_at)"); db.run("create index if not exists idx_computer_use_artifact_links_artifact on computer_use_artifact_links(artifact_id)"); - db.run(` - create table if not exists mission_artifacts ( - id text primary key, - mission_id text not null, - project_id text not null, - artifact_type text not null, - title text not null, - description text, - uri text, - lane_id text, - metadata_json text, - created_at text not null, - updated_at text not null, - created_by text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id) - ) - `); - db.run("create index if not exists idx_mission_artifacts_mission_created on mission_artifacts(mission_id, created_at)"); - - db.run(` - create table if not exists mission_interventions ( - id text primary key, - mission_id text not null, - project_id text not null, - intervention_type text not null, - status text not null, - resolution_kind text, - title text not null, - body text not null, - requested_action text, - resolution_note text, - lane_id text, - metadata_json text, - created_at text not null, - updated_at text not null, - resolved_at text, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id) - ) - `); - try { db.run("alter table mission_interventions add column resolution_kind text"); } catch {} - db.run("create index if not exists idx_mission_interventions_mission_status on mission_interventions(mission_id, status)"); - db.run("create index if not exists idx_mission_interventions_project_status on mission_interventions(project_id, status)"); - - // Phase 3: mission phases engine + profile storage. - db.run(` - create table if not exists phase_cards ( - id text primary key, - project_id text not null, - phase_key text not null, - name text not null, - description text not null, - instructions text not null, - model_json text not null, - budget_json text, - ordering_constraints_json text, - ask_questions_json text, - validation_gate_json text, - is_built_in integer not null default 0, - is_custom integer not null default 0, - position integer not null default 0, - archived_at text, - created_at text not null, - updated_at text not null, - foreign key(project_id) references projects(id) - ) - `); - db.run("create index if not exists idx_phase_cards_project_position on phase_cards(project_id, position)"); - - db.run(` - create table if not exists phase_profiles ( - id text primary key, - project_id text not null, - name text not null, - description text not null, - phases_json text not null, - is_built_in integer not null default 0, - is_default integer not null default 0, - archived_at text, - created_at text not null, - updated_at text not null, - foreign key(project_id) references projects(id) - ) - `); - db.run("create index if not exists idx_phase_profiles_project_updated on phase_profiles(project_id, updated_at)"); - db.run("create index if not exists idx_phase_profiles_project_default on phase_profiles(project_id, is_default)"); - - db.run(` - create table if not exists mission_phase_overrides ( - id text primary key, - mission_id text not null, - project_id text not null, - profile_id text, - phases_json text not null, - created_at text not null, - updated_at text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(profile_id) references phase_profiles(id) - ) - `); - db.run("create index if not exists idx_mission_phase_overrides_project_mission on mission_phase_overrides(project_id, mission_id)"); - db.run("create index if not exists idx_mission_phase_overrides_profile on mission_phase_overrides(profile_id)"); - - // Phase 1.5 orchestrator/context hardening gate. - db.run(` - create table if not exists orchestrator_runs ( - id text primary key, - project_id text not null, - mission_id text not null, - status text not null, - context_profile text not null default 'orchestrator_deterministic_v1', - scheduler_state text not null, - runtime_cursor_json text, - last_error text, - metadata_json text, - created_at text not null, - updated_at text not null, - started_at text, - completed_at text, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade - ) - `); - db.run("create index if not exists idx_orchestrator_runs_project_status on orchestrator_runs(project_id, status)"); - db.run("create index if not exists idx_orchestrator_runs_mission on orchestrator_runs(mission_id)"); - db.run("create index if not exists idx_orchestrator_runs_project_updated on orchestrator_runs(project_id, updated_at)"); - - db.run(` - create table if not exists orchestrator_steps ( - id text primary key, - run_id text not null, - project_id text not null, - mission_step_id text, - step_key text not null, - step_index integer not null, - title text not null, - lane_id text, - status text not null, - join_policy text not null default 'all_success', - quorum_count integer, - dependency_step_ids_json text not null default '[]', - retry_limit integer not null default 0, - retry_count integer not null default 0, - last_attempt_id text, - policy_json text, - metadata_json text, - created_at text not null, - updated_at text not null, - started_at text, - completed_at text, - foreign key(run_id) references orchestrator_runs(id), - foreign key(project_id) references projects(id), - foreign key(mission_step_id) references mission_steps(id), - foreign key(lane_id) references lanes(id) - ) - `); - db.run("create index if not exists idx_orchestrator_steps_run_status on orchestrator_steps(run_id, status)"); - db.run("create index if not exists idx_orchestrator_steps_project_status on orchestrator_steps(project_id, status)"); - db.run("create index if not exists idx_orchestrator_steps_run_order on orchestrator_steps(run_id, step_index)"); - - db.run(` - create table if not exists orchestrator_attempts ( - id text primary key, - run_id text not null, - step_id text not null, - project_id text not null, - attempt_number integer not null, - status text not null, - executor_kind text not null, - executor_session_id text, - tracked_session_enforced integer not null default 1, - context_profile text not null default 'orchestrator_deterministic_v1', - context_snapshot_id text, - error_class text not null default 'none', - error_message text, - retry_backoff_ms integer not null default 0, - result_envelope_json text, - metadata_json text, - created_at text not null, - started_at text, - completed_at text, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(project_id) references projects(id), - foreign key(context_snapshot_id) references orchestrator_context_snapshots(id) - ) - `); - db.run("create index if not exists idx_orchestrator_attempts_run_status on orchestrator_attempts(run_id, status)"); - db.run("create index if not exists idx_orchestrator_attempts_step_status on orchestrator_attempts(step_id, status)"); - db.run("create index if not exists idx_orchestrator_attempts_project_created on orchestrator_attempts(project_id, created_at)"); - - db.run(` - create table if not exists orchestrator_attempt_runtime ( - attempt_id text primary key, - session_id text, - runtime_state text, - last_signal_at text, - last_output_preview text, - last_preview_digest text, - digest_since_ms integer not null default 0, - repeat_count integer not null default 0, - last_waiting_intervention_at_ms integer not null default 0, - last_event_heartbeat_at_ms integer not null default 0, - last_waiting_notified_at_ms integer not null default 0, - updated_at text not null, - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_attempt_runtime_session on orchestrator_attempt_runtime(session_id)"); - db.run("create index if not exists idx_orchestrator_attempt_runtime_updated on orchestrator_attempt_runtime(updated_at)"); - - db.run(` - create table if not exists orchestrator_runtime_events ( - id text primary key, - project_id text not null, - run_id text not null, - step_id text, - attempt_id text, - session_id text, - event_type text not null, - event_key text not null, - occurred_at text not null, - payload_json text, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_runtime_events_run_occurred on orchestrator_runtime_events(run_id, occurred_at)"); - db.run("create index if not exists idx_orchestrator_runtime_events_attempt_occurred on orchestrator_runtime_events(attempt_id, occurred_at)"); - db.run("create index if not exists idx_orchestrator_runtime_events_session_occurred on orchestrator_runtime_events(session_id, occurred_at)"); - db.run("create index if not exists idx_orchestrator_runtime_events_project_key on orchestrator_runtime_events(project_id, event_key)"); - - db.run(` - create table if not exists orchestrator_claims ( - id text primary key, - project_id text not null, - run_id text not null, - step_id text, - attempt_id text, - owner_id text not null, - scope_kind text not null, - scope_value text not null, - state text not null, - acquired_at text not null, - heartbeat_at text not null, - expires_at text not null, - released_at text, - policy_json text, - metadata_json text, - foreign key(project_id) references projects(id), - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_claims_run_state on orchestrator_claims(run_id, state)"); - db.run("create index if not exists idx_orchestrator_claims_scope_state on orchestrator_claims(project_id, scope_kind, scope_value, state)"); - db.run("create index if not exists idx_orchestrator_claims_expires on orchestrator_claims(state, expires_at)"); - db.run( - "create index if not exists idx_orchestrator_claims_active_scope on orchestrator_claims(project_id, scope_kind, scope_value) where state = 'active'" - ); - - db.run(` - create table if not exists orchestrator_context_snapshots ( - id text primary key, - project_id text not null, - run_id text not null, - step_id text, - attempt_id text, - snapshot_type text not null, - context_profile text not null default 'orchestrator_deterministic_v1', - cursor_json text not null, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_context_snapshots_run_created on orchestrator_context_snapshots(run_id, created_at)"); - db.run("create index if not exists idx_orchestrator_context_snapshots_attempt on orchestrator_context_snapshots(attempt_id)"); - - db.run(` - create table if not exists mission_step_handoffs ( - id text primary key, - project_id text not null, - mission_id text not null, - mission_step_id text, - run_id text, - step_id text, - attempt_id text, - handoff_type text not null, - producer text not null, - payload_json text not null, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(mission_step_id) references mission_steps(id), - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_mission_step_handoffs_mission_created on mission_step_handoffs(mission_id, created_at)"); - db.run("create index if not exists idx_mission_step_handoffs_step_created on mission_step_handoffs(mission_step_id, created_at)"); - db.run("create index if not exists idx_mission_step_handoffs_attempt on mission_step_handoffs(attempt_id)"); - - // Phase 2 orchestrator runtime v2: durable timeline + quality gate snapshots. - db.run(` - create table if not exists orchestrator_timeline_events ( - id text primary key, - project_id text not null, - run_id text not null, - step_id text, - attempt_id text, - claim_id text, - event_type text not null, - reason text not null, - detail_json text, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id), - foreign key(claim_id) references orchestrator_claims(id) - ) - `); - db.run("create index if not exists idx_orchestrator_timeline_run_created on orchestrator_timeline_events(run_id, created_at)"); - db.run("create index if not exists idx_orchestrator_timeline_attempt on orchestrator_timeline_events(attempt_id)"); - db.run("create index if not exists idx_orchestrator_timeline_project_created on orchestrator_timeline_events(project_id, created_at)"); - - db.run(` - create table if not exists orchestrator_gate_reports ( - id text primary key, - project_id text not null, - generated_at text not null, - report_json text not null, - foreign key(project_id) references projects(id) - ) - `); - db.run("create index if not exists idx_orchestrator_gate_reports_project_generated on orchestrator_gate_reports(project_id, generated_at)"); - - // Big-bang orchestrator overhaul: threaded chat, digest/checkpoint, lane decisions, and mission metrics. - db.run(` - create table if not exists orchestrator_chat_threads ( - id text primary key, - project_id text not null, - mission_id text not null, - thread_type text not null, - title text not null, - run_id text, - step_id text, - step_key text, - attempt_id text, - session_id text, - lane_id text, - status text not null default 'active', - unread_count integer not null default 0, - metadata_json text, - created_at text not null, - updated_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id), - foreign key(lane_id) references lanes(id) - ) - `); - db.run("create index if not exists idx_orchestrator_chat_threads_mission_updated on orchestrator_chat_threads(mission_id, updated_at)"); - db.run("create index if not exists idx_orchestrator_chat_threads_project_mission on orchestrator_chat_threads(project_id, mission_id)"); - db.run("create index if not exists idx_orchestrator_chat_threads_mission_type on orchestrator_chat_threads(mission_id, thread_type)"); - db.run("create index if not exists idx_orchestrator_chat_threads_lane on orchestrator_chat_threads(lane_id)"); - - db.run(` - create table if not exists orchestrator_chat_messages ( - id text primary key, - project_id text not null, - mission_id text not null, - thread_id text not null, - role text not null, - content text not null, - timestamp text not null, - step_key text, - target_json text, - visibility text not null default 'full', - delivery_state text not null default 'delivered', - source_session_id text, - attempt_id text, - lane_id text, - run_id text, - metadata_json text, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(thread_id) references orchestrator_chat_threads(id), - foreign key(attempt_id) references orchestrator_attempts(id), - foreign key(lane_id) references lanes(id), - foreign key(run_id) references orchestrator_runs(id) - ) - `); - db.run("create index if not exists idx_orchestrator_chat_messages_thread_ts on orchestrator_chat_messages(thread_id, timestamp)"); - db.run("create index if not exists idx_orchestrator_chat_messages_mission_ts on orchestrator_chat_messages(mission_id, timestamp)"); - db.run("create index if not exists idx_orchestrator_chat_messages_attempt_ts on orchestrator_chat_messages(attempt_id, timestamp)"); - db.run("create index if not exists idx_orchestrator_chat_messages_lane_ts on orchestrator_chat_messages(lane_id, timestamp)"); - db.run("create index if not exists idx_orchestrator_chat_messages_delivery_queue on orchestrator_chat_messages(delivery_state, role, mission_id, thread_id, timestamp)"); - - db.run(` - create table if not exists orchestrator_worker_digests ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - step_id text not null, - step_key text, - attempt_id text not null, - lane_id text, - session_id text, - status text not null, - summary text not null, - files_changed_json text not null, - tests_run_json text not null, - warnings_json text not null, - tokens_json text, - cost_usd real, - suggested_next_actions_json text not null, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id), - foreign key(lane_id) references lanes(id) - ) - `); - db.run("create index if not exists idx_orchestrator_worker_digests_mission_created on orchestrator_worker_digests(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_worker_digests_run_created on orchestrator_worker_digests(run_id, created_at)"); - db.run("create index if not exists idx_orchestrator_worker_digests_attempt on orchestrator_worker_digests(attempt_id)"); - db.run("create index if not exists idx_orchestrator_worker_digests_lane_created on orchestrator_worker_digests(lane_id, created_at)"); - - db.run(` - create table if not exists orchestrator_artifacts ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - step_id text not null, - attempt_id text not null, - artifact_key text not null, - kind text not null, - value text not null, - metadata_json text not null default '{}', - declared integer not null default 0, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_artifacts_mission_created on orchestrator_artifacts(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_artifacts_step on orchestrator_artifacts(step_id)"); - db.run("create index if not exists idx_orchestrator_artifacts_mission_key on orchestrator_artifacts(mission_id, artifact_key)"); - - db.run(` - create table if not exists orchestrator_context_checkpoints ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text, - trigger text not null, - summary text not null, - source_json text not null, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id) - ) - `); - db.run("create index if not exists idx_orchestrator_context_checkpoints_mission_created on orchestrator_context_checkpoints(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_context_checkpoints_run_created on orchestrator_context_checkpoints(run_id, created_at)"); - - db.run(` - create table if not exists orchestrator_worker_checkpoints ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - step_id text not null, - attempt_id text not null, - step_key text not null, - content text not null, - file_path text not null, - created_at text not null, - updated_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_worker_checkpoints_mission_step_key on orchestrator_worker_checkpoints(mission_id, step_key)"); - db.run("create index if not exists idx_orchestrator_worker_checkpoints_run on orchestrator_worker_checkpoints(run_id)"); - db.run("create index if not exists idx_orchestrator_worker_checkpoints_mission on orchestrator_worker_checkpoints(mission_id, updated_at)"); - - db.run(` - create table if not exists orchestrator_lane_decisions ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text, - step_id text, - step_key text, - lane_id text, - decision_type text not null, - validator_outcome text not null, - rule_hits_json text not null, - rationale text not null, - metadata_json text, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(lane_id) references lanes(id) - ) - `); - db.run("create index if not exists idx_orchestrator_lane_decisions_mission_created on orchestrator_lane_decisions(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_lane_decisions_run_created on orchestrator_lane_decisions(run_id, created_at)"); - db.run("create index if not exists idx_orchestrator_lane_decisions_step_created on orchestrator_lane_decisions(step_id, created_at)"); - db.run("create index if not exists idx_orchestrator_lane_decisions_lane_created on orchestrator_lane_decisions(lane_id, created_at)"); - - db.run(` - create table if not exists orchestrator_ai_decisions ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text, - step_id text, - attempt_id text, - call_type text not null, - provider text, - model text, - timeout_cap_ms integer, - decision_json text not null, - action_trace_json text, - validation_json text, - rationale text, - fallback_used integer not null default 0, - failure_reason text, - duration_ms integer, - prompt_tokens integer, - completion_tokens integer, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_ai_decisions_mission_created on orchestrator_ai_decisions(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_ai_decisions_run_created on orchestrator_ai_decisions(run_id, created_at)"); - db.run("create index if not exists idx_orchestrator_ai_decisions_step_created on orchestrator_ai_decisions(step_id, created_at)"); - db.run("create index if not exists idx_orchestrator_ai_decisions_project_category_created on orchestrator_ai_decisions(project_id, call_type, created_at)"); - db.run("create index if not exists idx_orchestrator_ai_decisions_created on orchestrator_ai_decisions(created_at)"); - - db.run(` - create table if not exists mission_metrics_config ( - mission_id text primary key, - project_id text not null, - toggles_json text not null, - updated_at text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id) - ) - `); - db.run("create index if not exists idx_mission_metrics_config_project_updated on mission_metrics_config(project_id, updated_at)"); - - db.run(` - create table if not exists orchestrator_metrics_samples ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text, - attempt_id text, - metric text not null, - value real not null, - unit text, - metadata_json text, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(run_id) references orchestrator_runs(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ) - `); - db.run("create index if not exists idx_orchestrator_metrics_samples_mission_created on orchestrator_metrics_samples(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_metrics_samples_run_created on orchestrator_metrics_samples(run_id, created_at)"); - db.run("create index if not exists idx_orchestrator_metrics_samples_metric_created on orchestrator_metrics_samples(metric, created_at)"); // CTO persistent identity/core-continuity/session-log state. db.run(` @@ -2681,138 +1956,6 @@ function migrate(db: MigrationDb) { `); db.run("create index if not exists idx_agent_identities_project on agent_identities(project_id)"); - // Team runtime: persistent team member registry for agent-team orchestration. - db.run(` - create table if not exists orchestrator_team_members ( - id text primary key, - run_id text not null, - mission_id text not null, - provider text not null, - model text not null, - role text not null default 'teammate', - session_id text, - status text not null default 'spawning', - claimed_task_ids_json text not null default '[]', - metadata_json text, - created_at text not null, - updated_at text not null, - foreign key(run_id) references orchestrator_runs(id), - foreign key(mission_id) references missions(id) on delete cascade - ) - `); - db.run("create index if not exists idx_orchestrator_team_members_run on orchestrator_team_members(run_id)"); - db.run("create index if not exists idx_orchestrator_team_members_mission on orchestrator_team_members(mission_id)"); - db.run("create index if not exists idx_orchestrator_team_members_status on orchestrator_team_members(run_id, status)"); - - - // Reflection protocol tables (Phase 7): structured reflection ledger + run retrospectives. - db.run(` - create table if not exists orchestrator_reflections ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - step_id text, - attempt_id text, - agent_role text not null, - phase text not null, - signal_type text not null, - observation text not null, - recommendation text not null, - context text not null, - occurred_at text not null, - created_at text not null, - schema_version integer not null default 1, - foreign key(run_id) references orchestrator_runs(id) - ) - `); - db.run("create index if not exists idx_orchestrator_reflections_run_occurred on orchestrator_reflections(run_id, occurred_at)"); - db.run("create index if not exists idx_orchestrator_reflections_mission on orchestrator_reflections(mission_id, occurred_at)"); - - db.run(` - create table if not exists orchestrator_retrospectives ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - generated_at text not null, - final_status text not null, - payload_json text not null, - schema_version integer not null default 1, - created_at text not null, - foreign key(run_id) references orchestrator_runs(id) - ) - `); - db.run("create index if not exists idx_orchestrator_retrospectives_mission_generated on orchestrator_retrospectives(mission_id, generated_at)"); - - db.run(` - create table if not exists orchestrator_retrospective_trends ( - id text primary key, - project_id text not null, - mission_id text not null, - run_id text not null, - retrospective_id text not null, - source_mission_id text not null, - source_run_id text not null, - source_retrospective_id text not null, - pain_point_key text not null, - pain_point_label text not null, - status text not null, - previous_pain_score integer not null default 0, - current_pain_score integer not null default 0, - created_at text not null - ) - `); - db.run("create index if not exists idx_orchestrator_retrospective_trends_mission_created on orchestrator_retrospective_trends(mission_id, created_at)"); - db.run("create index if not exists idx_orchestrator_retrospective_trends_run_created on orchestrator_retrospective_trends(run_id, created_at)"); - - db.run(` - create table if not exists orchestrator_reflection_pattern_stats ( - id text primary key, - project_id text not null, - pattern_key text not null, - pattern_label text not null, - occurrence_count integer not null default 0, - first_seen_retrospective_id text not null, - first_seen_run_id text not null, - last_seen_retrospective_id text not null, - last_seen_run_id text not null, - created_at text not null, - updated_at text not null - ) - `); - db.run("create index if not exists idx_orchestrator_reflection_pattern_stats_count on orchestrator_reflection_pattern_stats(project_id, occurrence_count desc, updated_at desc)"); - - db.run(` - create table if not exists orchestrator_reflection_pattern_sources ( - id text primary key, - project_id text not null, - pattern_stat_id text not null, - retrospective_id text not null, - mission_id text not null, - run_id text not null, - created_at text not null, - foreign key(pattern_stat_id) references orchestrator_reflection_pattern_stats(id) - ) - `); - db.run("create index if not exists idx_orchestrator_reflection_pattern_sources_pattern on orchestrator_reflection_pattern_sources(pattern_stat_id, created_at)"); - db.run("create index if not exists idx_orchestrator_reflection_pattern_sources_mission on orchestrator_reflection_pattern_sources(mission_id, created_at)"); - // Team runtime: durable run-level state for team lifecycle (phase, completion gating). - db.run(` - create table if not exists orchestrator_run_state ( - run_id text primary key, - phase text not null default 'bootstrapping', - completion_requested integer not null default 0, - completion_validated integer not null default 0, - last_validation_error text, - coordinator_session_id text, - teammate_ids_json text not null default '[]', - created_at text not null, - updated_at text not null, - foreign key(run_id) references orchestrator_runs(id) - ) - `); - // Context compaction engine — transcript persistence for SDK agent sessions. db.run(` create table if not exists attempt_transcripts ( @@ -3043,7 +2186,6 @@ function migrate(db: MigrationDb) { action text not null, worker_id text, worker_slug text, - mission_id text, route_json text not null default '{}', attempt_count integer not null default 0, next_attempt_at text, @@ -3067,7 +2209,6 @@ function migrate(db: MigrationDb) { queue_item_id text, worker_id text, worker_slug text, - mission_id text, linear_assignee_id text, status text not null default 'active', claimed_at text not null, @@ -3129,7 +2270,6 @@ function migrate(db: MigrationDb) { current_step_index integer not null default 0, current_step_id text, execution_lane_id text, - linked_mission_id text, linked_session_id text, linked_worker_run_id text, linked_pr_id text, diff --git a/apps/desktop/src/main/services/state/projectState.ts b/apps/desktop/src/main/services/state/projectState.ts index 47d6b954d..85bb1d4c7 100644 --- a/apps/desktop/src/main/services/state/projectState.ts +++ b/apps/desktop/src/main/services/state/projectState.ts @@ -19,7 +19,6 @@ export type AdePaths = { chatSessionsDir: string; chatTranscriptsDir: string; orchestratorCacheDir: string; - missionStateDir: string; }; function resolveProjectRoot(): string { @@ -64,6 +63,5 @@ export function ensureAdeDirs(projectRoot: string): AdePaths { chatSessionsDir: layout.chatSessionsDir, chatTranscriptsDir: layout.chatTranscriptsDir, orchestratorCacheDir: layout.orchestratorCacheDir, - missionStateDir: layout.missionStateDir, }; } diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index 308c86313..8971a6b4a 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -144,7 +144,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, hostStartupEnabled: true, @@ -241,12 +240,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { - list: ({ status }: { status?: string } = {}) => - status === "active" - ? [{ id: "mission-1", title: "Ship W3", status: "active" }] - : [], - } as any, agentChatService: { listSessions: async () => [ { @@ -287,7 +280,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(readiness.ready).toBe(false); expect(readiness.blockers).toEqual( expect.arrayContaining([ - expect.objectContaining({ kind: "mission_run", id: "mission-1" }), expect.objectContaining({ kind: "chat_runtime", id: "chat-1", @@ -302,7 +294,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { ); expect(readiness.survivableState).toEqual( expect.arrayContaining([ - "Paused missions remain paused and can resume on the new host.", "CTO history and idle threads remain available on the new host.", "Idle and ended agent chats remain available and resumable on the new host.", ]), @@ -342,7 +333,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, }); @@ -428,7 +418,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, }); @@ -532,7 +521,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, forceHostRole: true, @@ -584,7 +572,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, hostStartupEnabled: false, @@ -630,7 +617,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, hostStartupEnabled: false, @@ -694,7 +680,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, hostStartupEnabled: true, @@ -751,7 +736,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, } as any); @@ -853,7 +837,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, }); @@ -904,7 +887,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, hostStartupEnabled: true, @@ -966,7 +948,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, forceHostRole: true, @@ -1004,7 +985,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, }); @@ -1048,7 +1028,6 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionService: { list: () => [] } as any, ptyService: {} as any, computerUseArtifactBrokerService: {} as any, - missionService: { list: () => [] } as any, agentChatService: { listSessions: async () => [] } as any, processService: { listRuntime: () => [] } as any, hostStartupEnabled: false, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 2271cfbd1..b4314d344 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -33,7 +33,6 @@ import type { LaneBranchSwitchPreview, LaneBranchSwitchResult, DeleteLaneArgs, - DeleteMissionArgs, DevToolsCheckResult, DiffChanges, DockLayout, @@ -244,8 +243,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - AddMissionArtifactArgs, - AddMissionInterventionArgs, KeybindingOverride, KeybindingsSnapshot, OnboardingDetectionResult, @@ -400,7 +397,6 @@ import type { UpdatePrDescriptionArgs, ListOverlapsArgs, LaneSummary, - ListMissionsArgs, ImportBranchLaneArgs, MergeSimulationArgs, MergeSimulationResult, @@ -468,126 +464,13 @@ import type { StopTestRunArgs, TerminalSessionDetail, TerminalSessionSummary, - ResolveMissionInterventionArgs, - MissionArtifact, - MissionDetail, - MissionIntervention, - OrchestratorAttempt, - OrchestratorGateReport, - OrchestratorRun, - OrchestratorRunGraph, - OrchestratorRuntimeEvent, - OrchestratorThreadEvent, - DagMutationEvent, - OrchestratorStep, - OrchestratorTimelineEvent, - MissionStep, - MissionSummary, - PhaseCard, - MissionsEventPayload, - ListPhaseItemsArgs, - SavePhaseItemArgs, - DeletePhaseItemArgs, - ImportPhaseItemsArgs, - ExportPhaseItemsArgs, - ExportPhaseItemsResult, - PhaseProfile, - SavePhaseProfileArgs, - ListPhaseProfilesArgs, - DeletePhaseProfileArgs, - ClonePhaseProfileArgs, - ExportPhaseProfileArgs, - ExportPhaseProfileResult, - ImportPhaseProfileArgs, - MissionPhaseConfiguration, - MissionDashboardSnapshot, - GetFullMissionViewArgs, - FullMissionViewResult, - MissionPreflightRequest, - MissionPreflightResult, - GetMissionRunViewArgs, - MissionRunView, - GetMissionLogsArgs, - GetMissionLogsResult, - ExportMissionLogsArgs, - ExportMissionLogsResult, - GetAggregatedUsageArgs, - AggregatedUsageStats, - GetOrchestratorGateReportArgs, - GetOrchestratorRunGraphArgs, - ListOrchestratorRunsArgs, - ListOrchestratorTimelineArgs, - CreateMissionArgs, - ArchiveMissionArgs, - CancelOrchestratorRunArgs, - CleanupOrchestratorTeamResourcesArgs, - CleanupOrchestratorTeamResourcesResult, - CompleteOrchestratorAttemptArgs, - HeartbeatOrchestratorClaimsArgs, - PauseOrchestratorRunArgs, - ResumeOrchestratorRunArgs, - StartOrchestratorAttemptArgs, - StartOrchestratorRunArgs, - StartOrchestratorRunFromMissionArgs, - TickOrchestratorRunArgs, - UpdateMissionArgs, UpdateSessionMetaArgs, - UpdateMissionStepArgs, TestEvent, TestRunSummary, TestSuiteDefinition, UpdateLaneAppearanceArgs, UndoConflictProposalArgs, WriteTextAtomicArgs, - GetOrchestratorWorkerStatesArgs, - OrchestratorWorkerState, - StartMissionRunWithAIArgs, - StartMissionRunWithAIResult, - SteerMissionArgs, - SteerMissionResult, - GetTeamMembersArgs, - GetTeamRuntimeStateArgs, - FinalizeRunArgs, - FinalizeRunResult, - OrchestratorTeamMember, - OrchestratorTeamRuntimeState, - GetModelCapabilitiesResult, - GetMissionMetricsArgs, - OrchestratorChatMessage, - OrchestratorChatThread, - OrchestratorContextCheckpoint, - OrchestratorLaneDecision, - OrchestratorWorkerDigest, - SendOrchestratorChatArgs, - GetOrchestratorChatArgs, - ListOrchestratorChatThreadsArgs, - GetOrchestratorThreadMessagesArgs, - SendOrchestratorThreadMessageArgs, - GetOrchestratorWorkerDigestArgs, - ListOrchestratorWorkerDigestsArgs, - GetOrchestratorContextCheckpointArgs, - ListOrchestratorLaneDecisionsArgs, - ListOrchestratorArtifactsArgs, - ListOrchestratorWorkerCheckpointsArgs, - MissionMetricsConfig, - MissionMetricSample, - SetMissionMetricsConfigArgs, - ExecutionPlanPreview, - GetMissionStateDocumentArgs, - MissionStateDocument, - OrchestratorArtifact, - OrchestratorWorkerCheckpoint, - GetOrchestratorPromptInspectorArgs, - GetPlanningPromptPreviewArgs, - OrchestratorPromptInspector, - GetMissionBudgetTelemetryArgs, - GetMissionBudgetStatusArgs, - MissionBudgetTelemetrySnapshot, - MissionBudgetSnapshot, - SendAgentMessageArgs, - GetGlobalChatArgs, - GetActiveAgentsArgs, - ActiveAgentInfo, InitLaneEnvArgs, GetLaneEnvStatusArgs, GetLaneOverlayArgs, @@ -1145,202 +1028,6 @@ declare global { onUpdate: (cb: (snapshot: UsageSnapshot) => void) => () => void; onThreshold: (cb: (event: UsageThresholdEvent) => void) => () => void; }; - missions: { - list: (args?: ListMissionsArgs) => Promise<MissionSummary[]>; - get: (missionId: string) => Promise<MissionDetail | null>; - create: (args: CreateMissionArgs) => Promise<MissionDetail>; - update: (args: UpdateMissionArgs) => Promise<MissionDetail>; - archive: (args: ArchiveMissionArgs) => Promise<void>; - delete: (args: DeleteMissionArgs) => Promise<void>; - updateStep: (args: UpdateMissionStepArgs) => Promise<MissionStep>; - addArtifact: (args: AddMissionArtifactArgs) => Promise<MissionArtifact>; - addIntervention: ( - args: AddMissionInterventionArgs, - ) => Promise<MissionIntervention>; - resolveIntervention: ( - args: ResolveMissionInterventionArgs, - ) => Promise<MissionIntervention>; - listPhaseItems: (args?: ListPhaseItemsArgs) => Promise<PhaseCard[]>; - savePhaseItem: (args: SavePhaseItemArgs) => Promise<PhaseCard>; - deletePhaseItem: (args: DeletePhaseItemArgs) => Promise<void>; - importPhaseItems: (args: ImportPhaseItemsArgs) => Promise<PhaseCard[]>; - exportPhaseItems: ( - args?: ExportPhaseItemsArgs, - ) => Promise<ExportPhaseItemsResult>; - listPhaseProfiles: ( - args?: ListPhaseProfilesArgs, - ) => Promise<PhaseProfile[]>; - savePhaseProfile: (args: SavePhaseProfileArgs) => Promise<PhaseProfile>; - deletePhaseProfile: (args: DeletePhaseProfileArgs) => Promise<void>; - clonePhaseProfile: ( - args: ClonePhaseProfileArgs, - ) => Promise<PhaseProfile>; - exportPhaseProfile: ( - args: ExportPhaseProfileArgs, - ) => Promise<ExportPhaseProfileResult>; - importPhaseProfile: ( - args: ImportPhaseProfileArgs, - ) => Promise<PhaseProfile>; - getPhaseConfiguration: ( - missionId: string, - ) => Promise<MissionPhaseConfiguration | null>; - getDashboard: () => Promise<MissionDashboardSnapshot>; - getFullMissionView: ( - args: GetFullMissionViewArgs, - ) => Promise<FullMissionViewResult>; - preflight: ( - args: MissionPreflightRequest, - ) => Promise<MissionPreflightResult>; - getRunView: ( - args: GetMissionRunViewArgs, - ) => Promise<MissionRunView | null>; - subscribeRunView: ( - args: GetMissionRunViewArgs, - cb: (view: MissionRunView | null) => void, - ) => () => void; - onEvent: (cb: (ev: MissionsEventPayload) => void) => () => void; - }; - orchestrator: { - listRuns: ( - args?: ListOrchestratorRunsArgs, - ) => Promise<OrchestratorRun[]>; - getRunGraph: ( - args: GetOrchestratorRunGraphArgs, - ) => Promise<OrchestratorRunGraph>; - startRun: ( - args: StartOrchestratorRunArgs, - ) => Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }>; - startRunFromMission: ( - args: StartOrchestratorRunFromMissionArgs, - ) => Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }>; - startAttempt: ( - args: StartOrchestratorAttemptArgs, - ) => Promise<OrchestratorAttempt>; - completeAttempt: ( - args: CompleteOrchestratorAttemptArgs, - ) => Promise<OrchestratorAttempt>; - tickRun: (args: TickOrchestratorRunArgs) => Promise<OrchestratorRun>; - pauseRun: (args: PauseOrchestratorRunArgs) => Promise<OrchestratorRun>; - resumeRun: ( - args: ResumeOrchestratorRunArgs, - ) => Promise<OrchestratorRun>; - cancelRun: ( - args: CancelOrchestratorRunArgs, - ) => Promise<OrchestratorRun>; - cleanupTeamResources: ( - args: CleanupOrchestratorTeamResourcesArgs, - ) => Promise<CleanupOrchestratorTeamResourcesResult>; - heartbeatClaims: ( - args: HeartbeatOrchestratorClaimsArgs, - ) => Promise<number>; - listTimeline: ( - args: ListOrchestratorTimelineArgs, - ) => Promise<OrchestratorTimelineEvent[]>; - getMissionLogs: ( - args: GetMissionLogsArgs, - ) => Promise<GetMissionLogsResult>; - exportMissionLogs: ( - args: ExportMissionLogsArgs, - ) => Promise<ExportMissionLogsResult>; - getGateReport: ( - args?: GetOrchestratorGateReportArgs, - ) => Promise<OrchestratorGateReport>; - getWorkerStates: ( - args: GetOrchestratorWorkerStatesArgs, - ) => Promise<OrchestratorWorkerState[]>; - startMissionRun: ( - args: StartMissionRunWithAIArgs, - ) => Promise<StartMissionRunWithAIResult>; - steerMission: (args: SteerMissionArgs) => Promise<SteerMissionResult>; - getModelCapabilities: () => Promise<GetModelCapabilitiesResult>; - getTeamMembers: ( - args: GetTeamMembersArgs, - ) => Promise<OrchestratorTeamMember[]>; - getTeamRuntimeState: ( - args: GetTeamRuntimeStateArgs, - ) => Promise<OrchestratorTeamRuntimeState | null>; - finalizeRun: (args: FinalizeRunArgs) => Promise<FinalizeRunResult>; - sendChat: ( - args: SendOrchestratorChatArgs, - ) => Promise<OrchestratorChatMessage>; - getChat: ( - args: GetOrchestratorChatArgs, - ) => Promise<OrchestratorChatMessage[]>; - listChatThreads: ( - args: ListOrchestratorChatThreadsArgs, - ) => Promise<OrchestratorChatThread[]>; - getThreadMessages: ( - args: GetOrchestratorThreadMessagesArgs, - ) => Promise<OrchestratorChatMessage[]>; - sendThreadMessage: ( - args: SendOrchestratorThreadMessageArgs, - ) => Promise<OrchestratorChatMessage>; - getWorkerDigest: ( - args: GetOrchestratorWorkerDigestArgs, - ) => Promise<OrchestratorWorkerDigest | null>; - listWorkerDigests: ( - args: ListOrchestratorWorkerDigestsArgs, - ) => Promise<OrchestratorWorkerDigest[]>; - getContextCheckpoint: ( - args: GetOrchestratorContextCheckpointArgs, - ) => Promise<OrchestratorContextCheckpoint | null>; - listLaneDecisions: ( - args: ListOrchestratorLaneDecisionsArgs, - ) => Promise<OrchestratorLaneDecision[]>; - listArtifacts: ( - args: ListOrchestratorArtifactsArgs, - ) => Promise<OrchestratorArtifact[]>; - listWorkerCheckpoints: ( - args: ListOrchestratorWorkerCheckpointsArgs, - ) => Promise<OrchestratorWorkerCheckpoint[]>; - getPromptInspector: ( - args: GetOrchestratorPromptInspectorArgs, - ) => Promise<OrchestratorPromptInspector>; - getPlanningPromptPreview: ( - args: GetPlanningPromptPreviewArgs, - ) => Promise<OrchestratorPromptInspector>; - getMissionMetrics: (args: GetMissionMetricsArgs) => Promise<{ - config: MissionMetricsConfig | null; - samples: MissionMetricSample[]; - }>; - setMissionMetricsConfig: ( - args: SetMissionMetricsConfigArgs, - ) => Promise<MissionMetricsConfig>; - getExecutionPlanPreview: (args: { - runId: string; - }) => Promise<ExecutionPlanPreview | null>; - getMissionStateDocument: ( - args: GetMissionStateDocumentArgs, - ) => Promise<MissionStateDocument | null>; - getCheckpointStatus: (args: { runId: string }) => Promise<{ - savedAt: string; - turnCount: number; - compactionCount: number; - } | null>; - getMissionBudgetStatus: ( - args: GetMissionBudgetStatusArgs, - ) => Promise<MissionBudgetSnapshot>; - getMissionBudgetTelemetry: ( - args: GetMissionBudgetTelemetryArgs, - ) => Promise<MissionBudgetTelemetrySnapshot>; - sendAgentMessage: ( - args: SendAgentMessageArgs, - ) => Promise<OrchestratorChatMessage>; - getGlobalChat: ( - args: GetGlobalChatArgs, - ) => Promise<OrchestratorChatMessage[]>; - getActiveAgents: ( - args: GetActiveAgentsArgs, - ) => Promise<ActiveAgentInfo[]>; - getAggregatedUsage: ( - args: GetAggregatedUsageArgs, - ) => Promise<AggregatedUsageStats>; - onEvent: (cb: (ev: OrchestratorRuntimeEvent) => void) => () => void; - onThreadEvent: ( - cb: (ev: OrchestratorThreadEvent) => void, - ) => () => void; - onDagMutation: (cb: (ev: DagMutationEvent) => void) => () => void; - }; lanes: { list: (args?: ListLanesArgs) => Promise<LaneSummary[]>; listSnapshots: (args?: ListLanesArgs) => Promise<LaneListSnapshot[]>; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 25926ec0f..2eb1c3215 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -140,8 +140,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - AddMissionArtifactArgs, - AddMissionInterventionArgs, AutomationsEventPayload, ConflictExternalResolverRunSummary, ConflictProposal, @@ -351,7 +349,6 @@ import type { LaneSummary, ListOverlapsArgs, ListLanesArgs, - ListMissionsArgs, ImportBranchLaneArgs, ListOperationsArgs, ListSessionsArgs, @@ -513,131 +510,17 @@ import type { StopTestRunArgs, TerminalSessionDetail, TerminalSessionSummary, - ResolveMissionInterventionArgs, - PhaseCard, - PhaseProfile, - ListPhaseItemsArgs, - SavePhaseItemArgs, - DeletePhaseItemArgs, - ExportPhaseItemsArgs, - ExportPhaseItemsResult, - ImportPhaseItemsArgs, - SavePhaseProfileArgs, - ListPhaseProfilesArgs, - DeletePhaseProfileArgs, - ClonePhaseProfileArgs, - ExportPhaseProfileArgs, - ExportPhaseProfileResult, - ImportPhaseProfileArgs, - MissionPhaseConfiguration, - MissionDashboardSnapshot, - GetFullMissionViewArgs, - FullMissionViewResult, - MissionPreflightRequest, - MissionPreflightResult, - GetMissionRunViewArgs, - MissionRunView, - ArchiveMissionArgs, - DeleteMissionArgs, - MissionArtifact, - MissionDetail, - MissionIntervention, - OrchestratorAttempt, - OrchestratorGateReport, - OrchestratorRuntimeEvent, - OrchestratorThreadEvent, - DagMutationEvent, - OrchestratorRun, - OrchestratorRunGraph, - OrchestratorStep, - OrchestratorTimelineEvent, - MissionStep, - MissionSummary, - MissionsEventPayload, - GetOrchestratorGateReportArgs, - GetOrchestratorRunGraphArgs, - ListOrchestratorRunsArgs, - ListOrchestratorTimelineArgs, - CreateMissionArgs, - CancelOrchestratorRunArgs, - CleanupOrchestratorTeamResourcesArgs, - CleanupOrchestratorTeamResourcesResult, - CompleteOrchestratorAttemptArgs, - HeartbeatOrchestratorClaimsArgs, - PauseOrchestratorRunArgs, - ResumeOrchestratorRunArgs, - StartOrchestratorAttemptArgs, - StartOrchestratorRunArgs, - StartOrchestratorRunFromMissionArgs, - TickOrchestratorRunArgs, - UpdateMissionArgs, UpdateSessionMetaArgs, - UpdateMissionStepArgs, TestEvent, TestRunSummary, TestSuiteDefinition, WriteTextAtomicArgs, - GetOrchestratorWorkerStatesArgs, - OrchestratorWorkerState, - StartMissionRunWithAIArgs, - StartMissionRunWithAIResult, - SteerMissionArgs, - SteerMissionResult, - GetTeamMembersArgs, - GetTeamRuntimeStateArgs, - FinalizeRunArgs, - FinalizeRunResult, - OrchestratorTeamMember, - OrchestratorTeamRuntimeState, - GetModelCapabilitiesResult, - OrchestratorChatMessage, - OrchestratorChatThread, - OrchestratorWorkerDigest, - OrchestratorContextCheckpoint, - OrchestratorLaneDecision, - MissionMetricsConfig, - MissionMetricSample, - SendOrchestratorChatArgs, - GetOrchestratorChatArgs, - ListOrchestratorChatThreadsArgs, - GetOrchestratorThreadMessagesArgs, - SendOrchestratorThreadMessageArgs, - GetOrchestratorWorkerDigestArgs, - ListOrchestratorWorkerDigestsArgs, - GetOrchestratorContextCheckpointArgs, - ListOrchestratorLaneDecisionsArgs, - GetMissionMetricsArgs, - SetMissionMetricsConfigArgs, - ExecutionPlanPreview, - GetMissionStateDocumentArgs, - MissionStateDocument, - ListOrchestratorArtifactsArgs, - ListOrchestratorWorkerCheckpointsArgs, - GetOrchestratorPromptInspectorArgs, - GetPlanningPromptPreviewArgs, - OrchestratorArtifact, - OrchestratorWorkerCheckpoint, - OrchestratorPromptInspector, - GetMissionLogsArgs, - GetMissionLogsResult, - ExportMissionLogsArgs, - ExportMissionLogsResult, - GetAggregatedUsageArgs, - AggregatedUsageStats, UsageSnapshot, UsageThresholdEvent, BudgetCheckResult, BudgetCapScope, BudgetCapProvider, BudgetCapConfig, - GetMissionBudgetTelemetryArgs, - GetMissionBudgetStatusArgs, - MissionBudgetTelemetrySnapshot, - MissionBudgetSnapshot, - SendAgentMessageArgs, - GetGlobalChatArgs, - GetActiveAgentsArgs, - ActiveAgentInfo, RuntimeDiagnosticsStatus, RuntimeDiagnosticsEvent, LaneHealthCheck, @@ -1512,18 +1395,6 @@ const remotePrAiResolutionEventCallbacks = new Set< const remoteProjectStateEventCallbacks = new Set< (payload: AdeProjectEvent) => void >(); -const remoteMissionEventCallbacks = new Set< - (payload: MissionsEventPayload) => void ->(); -const remoteOrchestratorEventCallbacks = new Set< - (payload: OrchestratorRuntimeEvent) => void ->(); -const remoteOrchestratorThreadEventCallbacks = new Set< - (payload: OrchestratorThreadEvent) => void ->(); -const remoteDagMutationEventCallbacks = new Set< - (payload: DagMutationEvent) => void ->(); const remoteSyncStatusEventCallbacks = new Set< (payload: SyncStatusEventPayload) => void >(); @@ -1693,10 +1564,6 @@ function shouldDispatchRemoteRuntimeEvent( function hasRemoteRuntimeEventSubscribers(): boolean { return ( remoteAgentChatEventCallbacks.size > 0 || - remoteMissionEventCallbacks.size > 0 || - remoteOrchestratorEventCallbacks.size > 0 || - remoteOrchestratorThreadEventCallbacks.size > 0 || - remoteDagMutationEventCallbacks.size > 0 || remoteSyncStatusEventCallbacks.size > 0 || remoteReviewEventCallbacks.size > 0 || remoteSessionChangedCallbacks.size > 0 || @@ -1918,13 +1785,7 @@ function toRemoteRuntimeBufferedEvent( if (typeof value.id !== "number" || !Number.isFinite(value.id)) return null; if (typeof value.timestamp !== "string") return null; const category = value.category; - if ( - category !== "orchestrator" && - category !== "dag_mutation" && - category !== "runtime" && - category !== "mission" && - category !== "pty" - ) { + if (category !== "runtime" && category !== "pty") { return null; } const payload = isRecord(value.payload) ? value.payload : {}; @@ -1943,16 +1804,6 @@ ipcRenderer.on(IPC.runtimeEvent, (_event, payload: unknown) => { function dispatchRemoteRuntimeEventPayload( payload: Record<string, unknown>, ): void { - if (payload.type === "missions-updated") { - for (const cb of [...remoteMissionEventCallbacks]) { - try { - cb(payload as MissionsEventPayload); - } catch (error) { - console.error("preload remote mission listener failed", error); - } - } - } - if (payload.type === "sync-status" && isRecord(payload.snapshot)) { for (const cb of [...remoteSyncStatusEventCallbacks]) { try { @@ -2115,55 +1966,6 @@ function dispatchRemoteRuntimeEventPayload( } } - if ( - payload.type === "orchestrator-run-updated" || - payload.type === "orchestrator-step-updated" || - payload.type === "orchestrator-attempt-updated" || - payload.type === "orchestrator-claim-updated" - ) { - for (const cb of [...remoteOrchestratorEventCallbacks]) { - try { - cb(payload as OrchestratorRuntimeEvent); - } catch (error) { - console.error("preload remote orchestrator listener failed", error); - } - } - } - - if ( - payload.type === "thread_updated" || - payload.type === "message_appended" || - payload.type === "message_updated" || - payload.type === "metrics_updated" || - payload.type === "worker_digest_updated" || - payload.type === "worker_replay" - ) { - for (const cb of [...remoteOrchestratorThreadEventCallbacks]) { - try { - cb(payload as OrchestratorThreadEvent); - } catch (error) { - console.error( - "preload remote orchestrator thread listener failed", - error, - ); - } - } - } - - if ( - typeof payload.runId === "string" && - isRecord(payload.mutation) && - typeof payload.timestamp === "string" - ) { - for (const cb of [...remoteDagMutationEventCallbacks]) { - try { - cb(payload as DagMutationEvent); - } catch (error) { - console.error("preload remote DAG mutation listener failed", error); - } - } - } - const chatEvent = toAgentChatEventEnvelope(payload); if (chatEvent) { agentChatSummaryCache.clear(); @@ -2441,46 +2243,6 @@ function subscribeRemoteAgentChatEvents( }; } -function subscribeRemoteMissionEvents( - cb: (payload: MissionsEventPayload) => void, -): () => void { - remoteMissionEventCallbacks.add(cb); - ensureRemoteRuntimeEventPump(); - return () => { - remoteMissionEventCallbacks.delete(cb); - }; -} - -function subscribeRemoteOrchestratorEvents( - cb: (payload: OrchestratorRuntimeEvent) => void, -): () => void { - remoteOrchestratorEventCallbacks.add(cb); - ensureRemoteRuntimeEventPump(); - return () => { - remoteOrchestratorEventCallbacks.delete(cb); - }; -} - -function subscribeRemoteOrchestratorThreadEvents( - cb: (payload: OrchestratorThreadEvent) => void, -): () => void { - remoteOrchestratorThreadEventCallbacks.add(cb); - ensureRemoteRuntimeEventPump(); - return () => { - remoteOrchestratorThreadEventCallbacks.delete(cb); - }; -} - -function subscribeRemoteDagMutationEvents( - cb: (payload: DagMutationEvent) => void, -): () => void { - remoteDagMutationEventCallbacks.add(cb); - ensureRemoteRuntimeEventPump(); - return () => { - remoteDagMutationEventCallbacks.delete(cb); - }; -} - function subscribeRemoteSyncStatusEvents( cb: (payload: SyncStatusEventPayload) => void, ): () => void { @@ -4305,754 +4067,6 @@ contextBridge.exposeInMainWorld("ade", { onUpdate: subscribeUsageUpdateEvents, onThreshold: subscribeUsageThresholdEvents, }, - missions: { - list: async (args: ListMissionsArgs = {}): Promise<MissionSummary[]> => - callProjectRuntimeActionOr("mission", "list", { args }, () => - ipcRenderer.invoke(IPC.missionsList, args), - ), - get: async (missionId: string): Promise<MissionDetail | null> => - callProjectRuntimeActionOr("mission", "get", { arg: missionId }, () => - ipcRenderer.invoke(IPC.missionsGet, { missionId }), - ), - create: async (args: CreateMissionArgs): Promise<MissionDetail> => - callProjectRuntimeActionOr("mission", "create", { args }, () => - ipcRenderer.invoke(IPC.missionsCreate, args), - ), - update: async (args: UpdateMissionArgs): Promise<MissionDetail> => - callProjectRuntimeActionOr("mission", "update", { args }, () => - ipcRenderer.invoke(IPC.missionsUpdate, args), - ), - archive: async (args: ArchiveMissionArgs): Promise<void> => - callProjectRuntimeActionOr("mission", "archive", { args }, () => - ipcRenderer.invoke(IPC.missionsArchive, args), - ), - delete: async (args: DeleteMissionArgs): Promise<void> => - callProjectRuntimeActionOr("mission", "delete", { args }, () => - ipcRenderer.invoke(IPC.missionsDelete, args), - ), - updateStep: async (args: UpdateMissionStepArgs): Promise<MissionStep> => - callProjectRuntimeActionOr("mission", "updateStep", { args }, () => - ipcRenderer.invoke(IPC.missionsUpdateStep, args), - ), - addArtifact: async ( - args: AddMissionArtifactArgs, - ): Promise<MissionArtifact> => - callProjectRuntimeActionOr("mission", "addArtifact", { args }, () => - ipcRenderer.invoke(IPC.missionsAddArtifact, args), - ), - addIntervention: async ( - args: AddMissionInterventionArgs, - ): Promise<MissionIntervention> => - callProjectRuntimeActionOr("mission", "addIntervention", { args }, () => - ipcRenderer.invoke(IPC.missionsAddIntervention, args), - ), - resolveIntervention: async ( - args: ResolveMissionInterventionArgs, - ): Promise<MissionIntervention> => - callProjectRuntimeActionOr( - "mission", - "resolveIntervention", - { args }, - () => ipcRenderer.invoke(IPC.missionsResolveIntervention, args), - ), - listPhaseItems: async ( - args: ListPhaseItemsArgs = {}, - ): Promise<PhaseCard[]> => - callProjectRuntimeActionOr("mission", "listPhaseItems", { args }, () => - ipcRenderer.invoke(IPC.missionsListPhaseItems, args), - ), - savePhaseItem: async (args: SavePhaseItemArgs): Promise<PhaseCard> => - callProjectRuntimeActionOr("mission", "savePhaseItem", { args }, () => - ipcRenderer.invoke(IPC.missionsSavePhaseItem, args), - ), - deletePhaseItem: async (args: DeletePhaseItemArgs): Promise<void> => - callProjectRuntimeActionOr("mission", "deletePhaseItem", { args }, () => - ipcRenderer.invoke(IPC.missionsDeletePhaseItem, args), - ).then(() => undefined), - importPhaseItems: async ( - args: ImportPhaseItemsArgs, - ): Promise<PhaseCard[]> => - callProjectRuntimeActionOr("mission", "importPhaseItems", { args }, () => - ipcRenderer.invoke(IPC.missionsImportPhaseItems, args), - ), - exportPhaseItems: async ( - args: ExportPhaseItemsArgs = {}, - ): Promise<ExportPhaseItemsResult> => - callProjectRuntimeActionOr("mission", "exportPhaseItems", { args }, () => - ipcRenderer.invoke(IPC.missionsExportPhaseItems, args), - ), - listPhaseProfiles: async ( - args: ListPhaseProfilesArgs = {}, - ): Promise<PhaseProfile[]> => - callProjectRuntimeActionOr("mission", "listPhaseProfiles", { args }, () => - ipcRenderer.invoke(IPC.missionsListPhaseProfiles, args), - ), - savePhaseProfile: async ( - args: SavePhaseProfileArgs, - ): Promise<PhaseProfile> => - callProjectRuntimeActionOr("mission", "savePhaseProfile", { args }, () => - ipcRenderer.invoke(IPC.missionsSavePhaseProfile, args), - ), - deletePhaseProfile: async (args: DeletePhaseProfileArgs): Promise<void> => - callProjectRuntimeActionOr( - "mission", - "deletePhaseProfile", - { args }, - () => ipcRenderer.invoke(IPC.missionsDeletePhaseProfile, args), - ).then(() => undefined), - clonePhaseProfile: async ( - args: ClonePhaseProfileArgs, - ): Promise<PhaseProfile> => - callProjectRuntimeActionOr("mission", "clonePhaseProfile", { args }, () => - ipcRenderer.invoke(IPC.missionsClonePhaseProfile, args), - ), - exportPhaseProfile: async ( - args: ExportPhaseProfileArgs, - ): Promise<ExportPhaseProfileResult> => - callProjectRuntimeActionOr( - "mission", - "exportPhaseProfile", - { args }, - () => ipcRenderer.invoke(IPC.missionsExportPhaseProfile, args), - ), - importPhaseProfile: async ( - args: ImportPhaseProfileArgs, - ): Promise<PhaseProfile> => - callProjectRuntimeActionOr( - "mission", - "importPhaseProfile", - { args }, - () => ipcRenderer.invoke(IPC.missionsImportPhaseProfile, args), - ), - getPhaseConfiguration: async ( - missionId: string, - ): Promise<MissionPhaseConfiguration | null> => - callProjectRuntimeActionOr( - "mission", - "getPhaseConfiguration", - { arg: missionId }, - () => - ipcRenderer.invoke(IPC.missionsGetPhaseConfiguration, { missionId }), - ), - getDashboard: async (): Promise<MissionDashboardSnapshot> => - callProjectRuntimeActionOr("mission", "getDashboard", {}, () => - ipcRenderer.invoke(IPC.missionsGetDashboard), - ), - getFullMissionView: async ( - args: GetFullMissionViewArgs, - ): Promise<FullMissionViewResult> => - callProjectRuntimeActionOr( - "mission", - "getFullMissionView", - { args }, - () => ipcRenderer.invoke(IPC.missionsGetFullMissionView, args), - ), - preflight: async ( - args: MissionPreflightRequest, - ): Promise<MissionPreflightResult> => - callProjectRuntimeActionOr("mission", "preflight", { args }, () => - ipcRenderer.invoke(IPC.missionsPreflight, args), - ), - getRunView: async ( - args: GetMissionRunViewArgs, - ): Promise<MissionRunView | null> => - callProjectRuntimeActionOr("mission", "getRunView", { args }, () => - ipcRenderer.invoke(IPC.missionsGetRunView, args), - ), - subscribeRunView: ( - args: GetMissionRunViewArgs, - cb: (view: MissionRunView | null) => void, - ) => { - let disposed = false; - let refreshTimer: ReturnType<typeof setTimeout> | null = null; - let inFlight = false; - let pending = false; - const refresh = () => { - if (disposed) return; - if (inFlight) { - pending = true; - return; - } - inFlight = true; - void callProjectRuntimeActionOr("mission", "getRunView", { args }, () => - ipcRenderer.invoke(IPC.missionsGetRunView, args), - ) - .then( - (view: MissionRunView | null) => { - if (!disposed) cb(view); - }, - () => {}, - ) - .finally(() => { - inFlight = false; - if (disposed || !pending) return; - pending = false; - scheduleRefresh(350); - }); - }; - const scheduleRefresh = (delayMs = 650) => { - if (disposed) return; - if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => { - refreshTimer = null; - refresh(); - }, delayMs); - }; - const missionListener = ( - _event: Electron.IpcRendererEvent, - payload: MissionsEventPayload, - ) => { - if (payload.missionId !== args.missionId) return; - scheduleRefresh(); - }; - const runtimeListener = ( - _event: Electron.IpcRendererEvent, - payload: OrchestratorRuntimeEvent, - ) => { - if (args.runId && payload.runId !== args.runId) return; - scheduleRefresh(); - }; - const threadListener = ( - _event: Electron.IpcRendererEvent, - payload: OrchestratorThreadEvent, - ) => { - if (payload.missionId !== args.missionId) return; - if (args.runId && payload.runId !== args.runId) return; - scheduleRefresh(750); - }; - const dagListener = ( - _event: Electron.IpcRendererEvent, - payload: DagMutationEvent, - ) => { - if (args.runId && payload.runId !== args.runId) return; - scheduleRefresh(750); - }; - ipcRenderer.on(IPC.missionsEvent, missionListener); - ipcRenderer.on(IPC.orchestratorEvent, runtimeListener); - ipcRenderer.on(IPC.orchestratorThreadEvent, threadListener); - ipcRenderer.on(IPC.orchestratorDagMutation, dagListener); - const removeRemoteMission = subscribeRemoteMissionEvents((payload) => { - if (payload.missionId !== args.missionId) return; - scheduleRefresh(); - }); - const removeRemoteOrchestrator = subscribeRemoteOrchestratorEvents( - (payload) => { - if (args.runId && payload.runId !== args.runId) return; - scheduleRefresh(); - }, - ); - const removeRemoteThread = subscribeRemoteOrchestratorThreadEvents( - (payload) => { - if (payload.missionId !== args.missionId) return; - if (args.runId && payload.runId !== args.runId) return; - scheduleRefresh(750); - }, - ); - const removeRemoteDag = subscribeRemoteDagMutationEvents((payload) => { - if (args.runId && payload.runId !== args.runId) return; - scheduleRefresh(750); - }); - refresh(); - return () => { - disposed = true; - if (refreshTimer) clearTimeout(refreshTimer); - removeRemoteMission(); - removeRemoteOrchestrator(); - removeRemoteThread(); - removeRemoteDag(); - ipcRenderer.removeListener(IPC.missionsEvent, missionListener); - ipcRenderer.removeListener(IPC.orchestratorEvent, runtimeListener); - ipcRenderer.removeListener(IPC.orchestratorThreadEvent, threadListener); - ipcRenderer.removeListener(IPC.orchestratorDagMutation, dagListener); - }; - }, - onEvent: (cb: (ev: MissionsEventPayload) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: MissionsEventPayload, - ) => cb(payload); - ipcRenderer.on(IPC.missionsEvent, listener); - const removeRemote = subscribeRemoteMissionEvents(cb); - return () => { - removeRemote(); - ipcRenderer.removeListener(IPC.missionsEvent, listener); - }; - }, - }, - orchestrator: { - listRuns: async ( - args: ListOrchestratorRunsArgs = {}, - ): Promise<OrchestratorRun[]> => - callProjectRuntimeActionOr( - "orchestrator_core", - "listRuns", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListRuns, args), - ), - getRunGraph: async ( - args: GetOrchestratorRunGraphArgs, - ): Promise<OrchestratorRunGraph> => - callProjectRuntimeActionOr( - "orchestrator_core", - "getRunGraph", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetRunGraph, args), - ), - startRun: async ( - args: StartOrchestratorRunArgs, - ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => - callProjectRuntimeActionOr( - "orchestrator_core", - "startRun", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorStartRun, args), - ), - startRunFromMission: async ( - args: StartOrchestratorRunFromMissionArgs, - ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => { - const launch = - await callProjectRuntimeActionOr<StartMissionRunWithAIResult>( - "orchestrator", - "startMissionRun", - { - args: { - missionId: args.missionId, - runMode: args.runMode, - autopilotOwnerId: args.autopilotOwnerId, - defaultExecutorKind: args.defaultExecutorKind, - defaultRetryLimit: args.defaultRetryLimit, - metadata: args.metadata ?? null, - plannerProvider: args.plannerProvider ?? undefined, - }, - }, - () => - ipcRenderer.invoke(IPC.orchestratorStartMissionRun, { - missionId: args.missionId, - runMode: args.runMode, - autopilotOwnerId: args.autopilotOwnerId, - defaultExecutorKind: args.defaultExecutorKind, - defaultRetryLimit: args.defaultRetryLimit, - metadata: args.metadata ?? null, - plannerProvider: args.plannerProvider ?? undefined, - }), - ); - if (!launch.started) { - throw new Error("Mission run did not produce a runnable execution."); - } - return launch.started; - }, - startAttempt: async ( - args: StartOrchestratorAttemptArgs, - ): Promise<OrchestratorAttempt> => - callProjectRuntimeActionOr( - "orchestrator_core", - "startAttempt", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorStartAttempt, args), - ), - completeAttempt: async ( - args: CompleteOrchestratorAttemptArgs, - ): Promise<OrchestratorAttempt> => - callProjectRuntimeActionOr( - "orchestrator_core", - "completeAttempt", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorCompleteAttempt, args), - ), - tickRun: async (args: TickOrchestratorRunArgs): Promise<OrchestratorRun> => - callProjectRuntimeActionOr("orchestrator_core", "tick", { args }, () => - ipcRenderer.invoke(IPC.orchestratorTickRun, args), - ), - pauseRun: async ( - args: PauseOrchestratorRunArgs, - ): Promise<OrchestratorRun> => - callProjectRuntimeActionOr( - "orchestrator_core", - "pauseRun", - { - args: { - runId: args.runId, - reason: args.reason ?? "Paused from Missions UI.", - }, - }, - () => ipcRenderer.invoke(IPC.orchestratorPauseRun, args), - ), - resumeRun: async ( - args: ResumeOrchestratorRunArgs, - ): Promise<OrchestratorRun> => - callProjectRuntimeActionOr("orchestrator", "resumeRun", { args }, () => - ipcRenderer.invoke(IPC.orchestratorResumeRun, args), - ), - cancelRun: async ( - args: CancelOrchestratorRunArgs, - ): Promise<OrchestratorRun> => - callProjectRuntimeActionOr( - "orchestrator", - "cancelRunGracefully", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorCancelRun, args), - ), - cleanupTeamResources: async ( - args: CleanupOrchestratorTeamResourcesArgs, - ): Promise<CleanupOrchestratorTeamResourcesResult> => - callProjectRuntimeActionOr( - "orchestrator", - "cleanupTeamResources", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorCleanupTeamResources, args), - ), - heartbeatClaims: async ( - args: HeartbeatOrchestratorClaimsArgs, - ): Promise<number> => - callProjectRuntimeActionOr( - "orchestrator_core", - "heartbeatClaims", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorHeartbeatClaims, args), - ), - listTimeline: async ( - args: ListOrchestratorTimelineArgs, - ): Promise<OrchestratorTimelineEvent[]> => - callProjectRuntimeActionOr( - "orchestrator_core", - "listTimeline", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListTimeline, args), - ), - getMissionLogs: async ( - args: GetMissionLogsArgs, - ): Promise<GetMissionLogsResult> => - callProjectRuntimeActionOr( - "orchestrator", - "getMissionLogs", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetMissionLogs, args), - ), - exportMissionLogs: async ( - args: ExportMissionLogsArgs, - ): Promise<ExportMissionLogsResult> => - callProjectRuntimeActionOr( - "orchestrator", - "exportMissionLogs", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorExportMissionLogs, args), - ), - getGateReport: async ( - args: GetOrchestratorGateReportArgs = {}, - ): Promise<OrchestratorGateReport> => - callProjectRuntimeActionOr( - "orchestrator_core", - "getLatestGateReport", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetGateReport, args), - ), - getWorkerStates: async ( - args: GetOrchestratorWorkerStatesArgs, - ): Promise<OrchestratorWorkerState[]> => - callProjectRuntimeActionOr( - "orchestrator", - "getWorkerStates", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetWorkerStates, args), - ), - startMissionRun: async ( - args: StartMissionRunWithAIArgs, - ): Promise<StartMissionRunWithAIResult> => - callProjectRuntimeActionOr( - "orchestrator", - "startMissionRun", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorStartMissionRun, args), - ), - steerMission: async (args: SteerMissionArgs): Promise<SteerMissionResult> => - callProjectRuntimeActionOr("orchestrator", "steerMission", { args }, () => - ipcRenderer.invoke(IPC.orchestratorSteerMission, args), - ), - getModelCapabilities: async (): Promise<GetModelCapabilitiesResult> => - callProjectRuntimeActionOr( - "orchestrator", - "getModelCapabilities", - {}, - () => ipcRenderer.invoke(IPC.orchestratorGetModelCapabilities), - ), - getTeamMembers: async ( - args: GetTeamMembersArgs, - ): Promise<OrchestratorTeamMember[]> => - callProjectRuntimeActionOr( - "orchestrator", - "getTeamMembers", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetTeamMembers, args), - ), - getTeamRuntimeState: async ( - args: GetTeamRuntimeStateArgs, - ): Promise<OrchestratorTeamRuntimeState | null> => - callProjectRuntimeActionOr( - "orchestrator_core", - "getRunState", - { arg: args.runId }, - () => ipcRenderer.invoke(IPC.orchestratorGetTeamRuntimeState, args), - ), - finalizeRun: async (args: FinalizeRunArgs): Promise<FinalizeRunResult> => - callProjectRuntimeActionOr("orchestrator", "finalizeRun", { args }, () => - ipcRenderer.invoke(IPC.orchestratorFinalizeRun, args), - ), - sendChat: async ( - args: SendOrchestratorChatArgs, - ): Promise<OrchestratorChatMessage> => - callProjectRuntimeActionOr("orchestrator", "sendChat", { args }, () => - ipcRenderer.invoke(IPC.orchestratorSendChat, args), - ), - getChat: async ( - args: GetOrchestratorChatArgs, - ): Promise<OrchestratorChatMessage[]> => - callProjectRuntimeActionOr("orchestrator", "getChat", { args }, () => - ipcRenderer.invoke(IPC.orchestratorGetChat, args), - ), - listChatThreads: async ( - args: ListOrchestratorChatThreadsArgs, - ): Promise<OrchestratorChatThread[]> => - callProjectRuntimeActionOr( - "orchestrator", - "listChatThreads", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListChatThreads, args), - ), - getThreadMessages: async ( - args: GetOrchestratorThreadMessagesArgs, - ): Promise<OrchestratorChatMessage[]> => - callProjectRuntimeActionOr( - "orchestrator", - "getThreadMessages", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetThreadMessages, args), - ), - sendThreadMessage: async ( - args: SendOrchestratorThreadMessageArgs, - ): Promise<OrchestratorChatMessage> => - callProjectRuntimeActionOr( - "orchestrator", - "sendThreadMessage", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorSendThreadMessage, args), - ), - getWorkerDigest: async ( - args: GetOrchestratorWorkerDigestArgs, - ): Promise<OrchestratorWorkerDigest | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getWorkerDigest", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetWorkerDigest, args), - ), - listWorkerDigests: async ( - args: ListOrchestratorWorkerDigestsArgs, - ): Promise<OrchestratorWorkerDigest[]> => - callProjectRuntimeActionOr( - "orchestrator", - "listWorkerDigests", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListWorkerDigests, args), - ), - getContextCheckpoint: async ( - args: GetOrchestratorContextCheckpointArgs, - ): Promise<OrchestratorContextCheckpoint | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getContextCheckpoint", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetContextCheckpoint, args), - ), - listLaneDecisions: async ( - args: ListOrchestratorLaneDecisionsArgs, - ): Promise<OrchestratorLaneDecision[]> => - callProjectRuntimeActionOr( - "orchestrator", - "listLaneDecisions", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListLaneDecisions, args), - ), - getMissionMetrics: async ( - args: GetMissionMetricsArgs, - ): Promise<{ - config: MissionMetricsConfig | null; - samples: MissionMetricSample[]; - }> => - callProjectRuntimeActionOr( - "orchestrator", - "getMissionMetrics", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetMissionMetrics, args), - ), - setMissionMetricsConfig: async ( - args: SetMissionMetricsConfigArgs, - ): Promise<MissionMetricsConfig> => - callProjectRuntimeActionOr( - "orchestrator", - "setMissionMetricsConfig", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorSetMissionMetricsConfig, args), - ), - getExecutionPlanPreview: async (args: { - runId: string; - }): Promise<ExecutionPlanPreview | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getExecutionPlanPreview", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetExecutionPlanPreview, args), - ), - getMissionStateDocument: async ( - args: GetMissionStateDocumentArgs, - ): Promise<MissionStateDocument | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getMissionStateDocument", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetMissionStateDocument, args), - ), - listArtifacts: async ( - args: ListOrchestratorArtifactsArgs, - ): Promise<OrchestratorArtifact[]> => - callProjectRuntimeActionOr( - "orchestrator", - "listArtifacts", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListArtifacts, args), - ), - listWorkerCheckpoints: async ( - args: ListOrchestratorWorkerCheckpointsArgs, - ): Promise<OrchestratorWorkerCheckpoint[]> => - callProjectRuntimeActionOr( - "orchestrator", - "listWorkerCheckpoints", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorListWorkerCheckpoints, args), - ), - getPromptInspector: async ( - args: GetOrchestratorPromptInspectorArgs, - ): Promise<OrchestratorPromptInspector | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getPromptInspector", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetPromptInspector, args), - ), - getPlanningPromptPreview: async ( - args: GetPlanningPromptPreviewArgs, - ): Promise<OrchestratorPromptInspector | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getPlanningPromptPreview", - { args }, - () => - ipcRenderer.invoke(IPC.orchestratorGetPlanningPromptPreview, args), - ), - getCheckpointStatus: async (args: { - runId: string; - }): Promise<{ - savedAt: string; - turnCount: number; - compactionCount: number; - } | null> => - callProjectRuntimeActionOr( - "orchestrator", - "getCheckpointStatus", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetCheckpointStatus, args), - ), - getMissionBudgetStatus: async ( - args: GetMissionBudgetStatusArgs, - ): Promise<MissionBudgetSnapshot> => - callProjectRuntimeActionOr( - "mission_budget", - "getMissionBudgetStatus", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetStatus, args), - ), - getMissionBudgetTelemetry: async ( - args: GetMissionBudgetTelemetryArgs, - ): Promise<MissionBudgetTelemetrySnapshot> => - callProjectRuntimeActionOr( - "mission_budget", - "getMissionBudgetTelemetry", - { args }, - () => - ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetTelemetry, args), - ), - sendAgentMessage: async ( - args: SendAgentMessageArgs, - ): Promise<OrchestratorChatMessage> => - callProjectRuntimeActionOr( - "orchestrator", - "sendAgentMessage", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorSendAgentMessage, args), - ), - getGlobalChat: async ( - args: GetGlobalChatArgs, - ): Promise<OrchestratorChatMessage[]> => - callProjectRuntimeActionOr( - "orchestrator", - "getGlobalChat", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetGlobalChat, args), - ), - getActiveAgents: async ( - args: GetActiveAgentsArgs, - ): Promise<ActiveAgentInfo[]> => - callProjectRuntimeActionOr( - "orchestrator", - "getActiveAgents", - { args }, - () => ipcRenderer.invoke(IPC.orchestratorGetActiveAgents, args), - ), - getAggregatedUsage: async ( - args: GetAggregatedUsageArgs, - ): Promise<AggregatedUsageStats> => - callProjectRuntimeActionOr( - "orchestrator", - "getAggregatedUsage", - { args }, - () => ipcRenderer.invoke(IPC.getAggregatedUsage, args), - ), - onEvent: (cb: (ev: OrchestratorRuntimeEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: OrchestratorRuntimeEvent, - ) => cb(payload); - ipcRenderer.on(IPC.orchestratorEvent, listener); - const removeRemote = subscribeRemoteOrchestratorEvents(cb); - return () => { - removeRemote(); - ipcRenderer.removeListener(IPC.orchestratorEvent, listener); - }; - }, - onThreadEvent: (cb: (ev: OrchestratorThreadEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: OrchestratorThreadEvent, - ) => cb(payload); - ipcRenderer.on(IPC.orchestratorThreadEvent, listener); - const removeRemote = subscribeRemoteOrchestratorThreadEvents(cb); - return () => { - removeRemote(); - ipcRenderer.removeListener(IPC.orchestratorThreadEvent, listener); - }; - }, - onDagMutation: (cb: (ev: DagMutationEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: DagMutationEvent, - ) => cb(payload); - ipcRenderer.on(IPC.orchestratorDagMutation, listener); - const removeRemote = subscribeRemoteDagMutationEvents(cb); - return () => { - removeRemote(); - ipcRenderer.removeListener(IPC.orchestratorDagMutation, listener); - }; - }, - }, lanes: { list: async (args: ListLanesArgs = {}): Promise<LaneSummary[]> => { const runtime = await callProjectRuntimeActionIfBound<LaneSummary[]>( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index ac8f403d0..fa8675071 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -481,8 +481,6 @@ function buildMockLanesFromAdeSnapshot(laneRows: any[]): any[] { icon: raw.icon ?? null, tags: Array.isArray(raw.tags) ? raw.tags : [], folder: raw.folder ?? null, - missionId: raw.missionId ?? null, - laneRole: raw.laneRole ?? null, createdAt: raw.createdAt ?? now, archivedAt: raw.archivedAt ?? null, }; @@ -2019,7 +2017,6 @@ const BUILTIN_MOCK_QUEUE_STATE: Record<string, any> = { permissionMode: "guarded_edit", confidenceThreshold: null, originSurface: "queue", - originMissionId: null, originRunId: null, originLabel: "Release v3.0 - Commerce", }, @@ -2061,7 +2058,6 @@ const BUILTIN_MOCK_QUEUE_STATE: Record<string, any> = { permissionMode: "guarded_edit", confidenceThreshold: null, originSurface: "queue", - originMissionId: null, originRunId: null, originLabel: "Billing Upgrade", }, @@ -2631,7 +2627,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { preset: "conservative", }; - /** Full enough for Settings, lane behavior, and Missions `applyMissionSettingsSnapshot` in the dev browser. */ + /** Full enough for Settings and lane behavior in the dev browser. */ const BROWSER_MOCK_PROJECT_CONFIG_SNAPSHOT: any = { shared: { version: 1, @@ -2708,40 +2704,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }; - const BROWSER_MOCK_MISSION_DASHBOARD: any = { - active: [], - recent: [], - weekly: { - missions: 0, - successRate: 0, - avgDurationMs: 0, - totalCostUsd: 0, - }, - }; - const BROWSER_MISSION_DASHBOARD: any = - USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionDashboard - ? ADE_DB_SNAPSHOT.missionDashboard - : BROWSER_MOCK_MISSION_DASHBOARD; - - const BROWSER_MOCK_EMPTY_FULL_MISSION_VIEW: any = { - mission: null, - runGraph: null, - artifacts: [], - checkpoints: [], - dashboard: null, - }; - - const BROWSER_MOCK_PHASE_PROFILE: any = { - id: "mock-profile", - name: "Mock profile", - description: "Browser mock phase profile", - phases: [] as any[], - isBuiltIn: true, - isDefault: true, - createdAt: now, - updatedAt: now, - }; - const BROWSER_MOCK_DEVTOOLS_CHECK: any = { tools: [ { @@ -3355,11 +3317,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, executor: { mode: "automation-bot" }, modelConfig: { - orchestratorModel: { modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium", }, - }, permissionConfig: { providers: { opencode: "edit", @@ -3371,7 +3331,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { prompt: "Review the latest PR update and leave a concise follow-up summary with any high-signal next steps.", reviewProfile: "incremental", - toolPalette: ["repo", "git", "github", "mission"], + toolPalette: ["repo", "git", "github"], contextSources: [], guardrails: {}, outputs: { disposition: "comment-only", createArtifact: true }, @@ -3398,7 +3358,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { id: "run-1", automationId: "auto-session-review", chatSessionId: "chat-auto-1", - missionId: null, triggerType: "manual", startedAt: now, endedAt: now, @@ -3421,7 +3380,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { id: "run-1", automationId: "auto-session-review", chatSessionId: "chat-auto-1", - missionId: null, triggerType: "git.pr_updated", startedAt: now, endedAt: now, @@ -3448,7 +3406,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { id: "run-1", automationId: "auto-session-review", chatSessionId: "chat-auto-1", - missionId: null, triggerType: "git.pr_updated", startedAt: now, endedAt: now, @@ -3514,7 +3471,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { id: "run-1", automationId: "auto-session-review", chatSessionId: "chat-auto-1", - missionId: null, triggerType: "git.pr_updated", startedAt: now, endedAt: now, @@ -3589,7 +3545,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { executor: { mode: "automation-bot" }, prompt: "Review the latest changes.", reviewProfile: "quick", - toolPalette: ["repo", "mission"], + toolPalette: ["repo"], contextSources: [], guardrails: {}, outputs: { disposition: "comment-only", createArtifact: true }, @@ -3891,269 +3847,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { actions: { listRegistry: resolved([]), }, - missions: { - list: async (args: any = {}) => { - const rows = - USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) - ? ADE_DB_SNAPSHOT.missions - : []; - const status = typeof args?.status === "string" ? args.status : null; - const laneId = typeof args?.laneId === "string" ? args.laneId : null; - const includeArchived = args?.includeArchived === true; - const activeStatuses = new Set([ - "queued", - "planning", - "plan_review", - "in_progress", - "intervention_required", - ]); - let filtered = rows; - if (!includeArchived) - filtered = filtered.filter((mission: any) => !mission.archivedAt); - if (laneId) - filtered = filtered.filter( - (mission: any) => mission.laneId === laneId, - ); - if (status === "active") { - filtered = filtered.filter((mission: any) => - activeStatuses.has(mission.status), - ); - } else if (status === "in_progress") { - filtered = filtered.filter( - (mission: any) => - mission.status === "in_progress" || - mission.status === "plan_review", - ); - } else if (status) { - filtered = filtered.filter( - (mission: any) => mission.status === status, - ); - } - const limit = Number.isFinite(args?.limit) - ? Math.max(1, Math.floor(args.limit)) - : filtered.length; - return filtered.slice(0, limit); - }, - get: async (missionId: string) => { - const rows = - USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) - ? ADE_DB_SNAPSHOT.missions - : []; - return rows.find((mission: any) => mission.id === missionId) ?? null; - }, - create: resolvedArg({ id: "mock" }), - update: resolvedArg({ id: "mock" }), - archive: resolvedArg(undefined), - delete: resolvedArg(undefined), - updateStep: resolvedArg({ id: "mock" }), - addArtifact: resolvedArg({ id: "mock" }), - addIntervention: resolvedArg({ id: "mock" }), - resolveIntervention: resolvedArg({ id: "mock" }), - listPhaseItems: resolved([]), - savePhaseItem: resolvedArg({} as any), - deletePhaseItem: resolvedArg(undefined), - importPhaseItems: resolvedArg([]), - exportPhaseItems: resolvedArg({ items: [], savedPath: null }), - listPhaseProfiles: resolved([]), - savePhaseProfile: resolvedArg({ - ...BROWSER_MOCK_PHASE_PROFILE, - id: "mock-profile-saved", - name: "Saved profile", - } as any), - deletePhaseProfile: resolvedArg(undefined), - clonePhaseProfile: resolvedArg({ - ...BROWSER_MOCK_PHASE_PROFILE, - id: "mock-profile-clone", - isBuiltIn: false, - isDefault: false, - name: "Cloned profile", - } as any), - exportPhaseProfile: resolvedArg({ - profile: BROWSER_MOCK_PHASE_PROFILE, - savedPath: null, - } as any), - importPhaseProfile: resolvedArg({ - ...BROWSER_MOCK_PHASE_PROFILE, - id: "mock-profile-imported", - isBuiltIn: false, - isDefault: false, - name: "Imported profile", - } as any), - getPhaseConfiguration: resolvedArg(null), - getDashboard: resolved(BROWSER_MISSION_DASHBOARD), - getFullMissionView: async (missionId: string) => - USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionFullViews?.[missionId] - ? ADE_DB_SNAPSHOT.missionFullViews[missionId] - : BROWSER_MOCK_EMPTY_FULL_MISSION_VIEW, - preflight: resolvedArg({ - canLaunch: true, - checkedAt: now, - profileName: null, - selectedPhaseCount: 0, - hardFailures: 0, - warnings: 0, - checklist: [] as any[], - budgetEstimate: null, - } as any), - getRunView: resolvedArg(null), - subscribeRunView: () => () => {}, - onEvent: noop, - }, - orchestrator: { - listRuns: resolved([]), - getRunGraph: resolvedArg({ nodes: [], edges: [] }), - startRun: resolvedArg({ run: {}, steps: [] }), - startRunFromMission: resolvedArg({ run: {}, steps: [] }), - startAttempt: resolvedArg({}), - completeAttempt: resolvedArg({}), - tickRun: resolvedArg({}), - resumeRun: resolvedArg({}), - cancelRun: resolvedArg({}), - cleanupTeamResources: resolvedArg({ - missionId: "mock", - runId: null, - laneIds: [], - lanesArchived: [], - lanesSkipped: [], - laneErrors: [], - }), - heartbeatClaims: resolvedArg(0), - listTimeline: resolvedArg([]), - getGateReport: resolved({}), - getWorkerStates: resolvedArg([]), - startMissionRun: resolvedArg({}), - steerMission: resolvedArg({}), - getTeamMembers: async (args: { runId?: string } = {}) => { - const runId = - typeof args.runId === "string" && args.runId.trim().length > 0 - ? args.runId.trim() - : "mock-run"; - const missionId = "mock-mission"; - const nowIso = new Date().toISOString(); - return [ - { - id: "coordinator", - runId, - missionId, - provider: "claude", - model: "anthropic/claude-sonnet-4-6", - role: "coordinator", - source: "ade-worker", - parentWorkerId: null, - sessionId: "session-coordinator", - status: "active", - claimedTaskIds: ["plan_phase"], - metadata: { teamRole: "coordinator", source: "ade-worker" }, - createdAt: nowIso, - updatedAt: nowIso, - }, - { - id: "implement-api", - runId, - missionId, - provider: "codex", - model: DEFAULT_BROWSER_MOCK_CODEX_MODEL, - role: "worker", - source: "ade-worker", - parentWorkerId: null, - sessionId: "session-impl-api", - status: "active", - claimedTaskIds: ["api_step"], - metadata: { teamRole: "implementer", source: "ade-worker" }, - createdAt: nowIso, - updatedAt: nowIso, - }, - { - id: "api-tests", - runId, - missionId, - provider: "claude", - model: "anthropic/claude-3-5-haiku", - role: "teammate", - source: "ade-subagent", - parentWorkerId: "implement-api", - sessionId: "session-api-tests", - status: "idle", - claimedTaskIds: [], - metadata: { - teamRole: "validator", - source: "ade-subagent", - parentWorkerId: "implement-api", - isSubAgent: true, - }, - createdAt: nowIso, - updatedAt: nowIso, - }, - { - id: "native-reviewer", - runId, - missionId, - provider: "claude", - model: "anthropic/claude-sonnet-4-6", - role: "teammate", - source: "claude-native", - parentWorkerId: "implement-api", - sessionId: "session-native-reviewer", - status: "active", - claimedTaskIds: [], - metadata: { - source: "claude-native", - parentWorkerId: "implement-api", - teamRole: "specialist", - }, - createdAt: nowIso, - updatedAt: nowIso, - }, - ]; - }, - listArtifacts: resolvedArg([]), - listWorkerCheckpoints: resolvedArg([]), - getPromptInspector: resolvedArg({ - target: "coordinator", - runId: "mock-run", - missionId: "mock-mission", - stepId: null, - phaseKey: null, - phaseName: null, - title: "Mock prompt inspector", - notes: [], - layers: [], - fullPrompt: "", - }), - getTeamRuntimeState: resolvedArg(null), - finalizeRun: resolvedArg({ success: true, blockers: [] }), - getModelCapabilities: resolved({ models: [] }), - sendChat: resolvedArg({}), - getChat: resolvedArg([]), - listChatThreads: resolvedArg([]), - getThreadMessages: resolvedArg([]), - sendThreadMessage: resolvedArg({}), - getWorkerDigest: resolvedArg(null), - listWorkerDigests: resolvedArg([]), - getContextCheckpoint: resolvedArg(null), - listLaneDecisions: resolvedArg([]), - getMissionMetrics: resolvedArg({ config: null, samples: [] }), - setMissionMetricsConfig: resolvedArg({}), - getExecutionPlanPreview: resolvedArg(null), - getMissionStateDocument: resolvedArg(null), - getCheckpointStatus: resolvedArg(null), - sendAgentMessage: resolvedArg({}), - getGlobalChat: resolvedArg([]), - getActiveAgents: resolvedArg([]), - getMissionBudgetTelemetry: resolvedArg({ - computedAt: new Date().toISOString(), - perProvider: [], - dataSources: [], - }), - getAggregatedUsage: resolvedArg({ - totalTokens: 0, - totalCost: 0, - byModel: [], - }), - onEvent: noop, - onThreadEvent: noop, - onDagMutation: noop, - }, lanes: { list: resolved(MOCK_LANES), listSnapshots: async () => @@ -5103,8 +4796,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { icon: null, tags: [], folder: null, - missionId: null, - laneRole: null, status: { dirty: false, ahead: 0, diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 307a1d4a8..bd72f39c7 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -81,9 +81,6 @@ const SettingsPage = React.lazy(() => const WorkspaceGraphPage = React.lazy(() => import("../graph/WorkspaceGraphPage").then((m) => ({ default: m.WorkspaceGraphPage })) ); -const MissionsPage = React.lazy(() => - import("../missions/MissionsPage").then((m) => ({ default: m.MissionsPage })) -); const ctoRoute = createPreloadableRoute<{ active?: boolean }>(() => import("../cto/CtoPage").then((m) => ({ default: m.CtoPage })) ); @@ -270,7 +267,6 @@ function serializeProjectRoute(location: ReturnType<typeof useLocation>): string "/review", "/history", "/automations", - "/missions", "/cto", "/settings", "/onboarding", @@ -461,11 +457,6 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string <React.Suspense fallback={LazyFallback}>{React.createElement(AutomationsTemplatesPage as React.ComponentType<{ active?: boolean }>, routeProps)}</React.Suspense> </PageErrorBoundary> } /> - <Route path="/missions" element={ - <PageErrorBoundary> - <React.Suspense fallback={LazyFallback}>{React.createElement(MissionsPage as React.ComponentType<{ active?: boolean }>, routeProps)}</React.Suspense> - </PageErrorBoundary> - } /> <Route path="/cto" element={ <PageErrorBoundary> <React.Suspense fallback={LazyFallback}>{React.createElement(CtoPage as React.ComponentType<{ active?: boolean }>, routeProps)}</React.Suspense> diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 1b295d43f..eea295066 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -56,7 +56,7 @@ type PrToast = { }; function primaryTabPath(pathname: string): string { - const roots = ["/project", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/missions", "/cto", "/vm", "/settings"]; + const roots = ["/project", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/cto", "/vm", "/settings"]; return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } @@ -81,7 +81,6 @@ function serializeLocationRoute(location: ReturnType<typeof useLocation>): strin "/review", "/history", "/automations", - "/missions", "/cto", "/vm", "/settings", @@ -1070,8 +1069,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { "/review": "tab-tint-review", "/history": "tab-tint-history", "/automations": "tab-tint-automations", - "/missions": "tab-tint-missions", - "/cto": "tab-tint-missions", + "/cto": "tab-tint-cto", "/vm": "tab-tint-work", "/settings": "tab-tint-settings", }; diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index ec4634e16..520bc0021 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -502,13 +502,6 @@ export function CommandPalette({ group: "Navigation", run: () => navigate("/history"), }, - { - id: "go-missions", - title: "Go to Missions", - shortcut: "G M", - group: "Navigation", - run: () => navigate("/missions"), - }, { id: "go-automations", title: "Go to Automations", diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 5e7b9ba33..6141c2dc8 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -10,7 +10,6 @@ import { MagnifyingGlass, ClockCounterClockwise, Robot, - Strategy, Brain, DesktopTower, GearSix, @@ -33,7 +32,6 @@ const mainItems = [ { to: "/cto", label: "CTO", icon: Brain }, { to: "/graph", label: "Graph", icon: Graph }, { to: "/history", label: "History", icon: ClockCounterClockwise }, - { to: "/missions", label: "Missions", icon: Strategy }, { to: "/vm", label: "VM", icon: DesktopTower }, ] as const; @@ -185,14 +183,6 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) )} /> ) : null} - {it.to === "/missions" && isPackaged ? ( - <span - title="Missions are coming soon in production builds" - className="absolute -right-2 -top-1 rounded border border-emerald-300/40 bg-emerald-400 px-1 font-mono text-[7px] font-bold uppercase leading-[10px] text-[#07110B]" - > - Soon - </span> - ) : null} {it.to === "/vm" && isPackaged ? ( <span title="Mac VM is coming soon in production builds" diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index a73e97cfe..8926eb9f8 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -219,7 +219,6 @@ describe("TopBar", () => { lanes: { list: vi.fn(async () => []) }, sessions: { list: vi.fn(async () => []) }, agentChat: { list: vi.fn(async () => []) }, - missions: { list: vi.fn(async () => []) }, processes: { listRuntime: vi.fn(async () => []) }, } as any; }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index b377257bf..165fbd1d1 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -940,12 +940,11 @@ export function TopBar() { if (project?.rootPath !== projectRootPath) return true; try { - const [lanes, runningSessions, agentChats, activeMissions] = + const [lanes, runningSessions, agentChats] = await Promise.all([ window.ade.lanes.list({ includeArchived: false }), window.ade.sessions.list({ status: "running" }), window.ade.agentChat.list(), - window.ade.missions.list({ status: "active" }), ]); const laneRuntimes = await Promise.all( @@ -985,12 +984,6 @@ export function TopBar() { `${activeChatCount} active chat${activeChatCount === 1 ? "" : "s"}`, ); } - if (activeMissions.length > 0) { - warnings.push( - `${activeMissions.length} active mission${activeMissions.length === 1 ? "" : "s"}`, - ); - } - if (warnings.length === 0) return true; const message = [ diff --git a/apps/desktop/src/renderer/components/automations/ActionList.tsx b/apps/desktop/src/renderer/components/automations/ActionList.tsx index 06c0303c2..66ef9fbcb 100644 --- a/apps/desktop/src/renderer/components/automations/ActionList.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionList.tsx @@ -4,7 +4,6 @@ import { Code, Lightning, Plus, - Rocket, TerminalWindow, TestTube, Warning, @@ -65,13 +64,6 @@ const ADD_OPTIONS: readonly AddOption[] = [ accent: "#F97316", description: "Run the conflict prediction pass against recent lanes.", }, - { - kind: "launch-mission", - label: "Launch mission", - icon: Rocket, - accent: "#94A3B8", - description: "Spin up a multi-step mission for this rule.", - }, ]; function createBlankAction(kind: ActionRowKind, suites: TestSuiteDefinition[]): ActionRowValue { @@ -92,8 +84,6 @@ function createBlankAction(kind: ActionRowKind, suites: TestSuiteDefinition[]): return { kind, command: "", cwd: "" }; case "predict-conflicts": return { kind }; - case "launch-mission": - return { kind, missionTitle: "" }; } } diff --git a/apps/desktop/src/renderer/components/automations/ActionRow.tsx b/apps/desktop/src/renderer/components/automations/ActionRow.tsx index f8e5119b0..2ff69ed38 100644 --- a/apps/desktop/src/renderer/components/automations/ActionRow.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionRow.tsx @@ -4,7 +4,6 @@ import { Code, GitBranch, Lightning, - Rocket, TerminalWindow, TestTube, Trash, @@ -12,13 +11,13 @@ import { } from "@phosphor-icons/react"; import type { ElementType } from "react"; import type { - MissionPermissionConfig, + AutomationRuleDraft, ModelConfig, TestSuiteDefinition, } from "../../../shared/types"; import { Chip } from "../ui/Chip"; import { cn } from "../ui/cn"; -import { ModelSelector } from "../missions/ModelSelector"; +import { ModelSelector } from "../shared/ModelSelector"; import { permissionControlsForModel, patchPermissionConfig } from "./permissionControls"; import { inputCls, labelCls, selectCls, textareaCls } from "./designTokens"; import { AdeActionEditor, type AdeActionValue } from "./AdeActionEditor"; @@ -29,8 +28,9 @@ export type ActionRowKind = | "ade-action" | "run-tests" | "run-command" - | "predict-conflicts" - | "launch-mission"; + | "predict-conflicts"; + +type AutomationPermissionConfig = NonNullable<AutomationRuleDraft["permissionConfig"]>; export type ActionRowValue = { kind: ActionRowKind; @@ -45,7 +45,7 @@ export type ActionRowValue = { sessionTitle?: string; modelConfig?: ModelConfig; codexFastMode?: boolean; - permissionConfig?: MissionPermissionConfig; + permissionConfig?: AutomationPermissionConfig; // Create lane laneNameTemplate?: string; laneDescriptionTemplate?: string; @@ -57,8 +57,6 @@ export type ActionRowValue = { // run-command command?: string; cwd?: string; - // Mission - missionTitle?: string; }; const KIND_META: Record<ActionRowKind, { label: string; icon: ElementType; accent: string }> = { @@ -68,7 +66,6 @@ const KIND_META: Record<ActionRowKind, { label: string; icon: ElementType; accen "run-tests": { label: "Run tests", icon: TestTube, accent: "#22C55E" }, "run-command": { label: "Run command", icon: TerminalWindow, accent: "#F59E0B" }, "predict-conflicts": { label: "Predict conflicts", icon: Warning, accent: "#F97316" }, - "launch-mission": { label: "Launch mission", icon: Rocket, accent: "#94A3B8" }, }; export function ActionRow({ @@ -335,18 +332,6 @@ export function ActionRow({ </div> ) : null} - {value.kind === "launch-mission" ? ( - <div className="space-y-2"> - <input - className={inputCls} - value={value.missionTitle ?? ""} - onChange={(event) => onChange({ ...value, missionTitle: event.target.value })} - placeholder="Mission title" - /> - <Chip className="text-[9px] text-[#B6B2C9]">Starts a mission and records the mission id in run history.</Chip> - </div> - ) : null} - {value.kind !== "create-lane" ? ( <RuntimeControls value={value} @@ -365,8 +350,7 @@ function supportsLaneOverride(kind: ActionRowKind): boolean { return kind === "agent-session" || kind === "run-tests" || kind === "run-command" - || kind === "predict-conflicts" - || kind === "launch-mission"; + || kind === "predict-conflicts"; } function secondsFromMs(ms: number | undefined): number | "" { diff --git a/apps/desktop/src/renderer/components/automations/AutomationsPage.tsx b/apps/desktop/src/renderer/components/automations/AutomationsPage.tsx index 02d3b4a1d..04055756a 100644 --- a/apps/desktop/src/renderer/components/automations/AutomationsPage.tsx +++ b/apps/desktop/src/renderer/components/automations/AutomationsPage.tsx @@ -7,28 +7,11 @@ export function AutomationsPage({ active = true }: { active?: boolean } = {}) { const navigate = useNavigate(); const location = useLocation(); const [pendingDraft, setPendingDraft] = useState<AutomationRuleDraft | null>(null); - const [missionsEnabled, setMissionsEnabled] = useState(true); useEffect(() => { console.info(`renderer.page ${JSON.stringify({ page: "automations" })}`); }, []); - useEffect(() => { - if (!active) return; - let cancelled = false; - window.ade.app.getInfo().then( - (info) => { - if (!cancelled) setMissionsEnabled(!info.isPackaged); - }, - () => { - if (!cancelled) setMissionsEnabled(true); - }, - ); - return () => { - cancelled = true; - }; - }, [active]); - // Templates screen navigates here with { draft } in location state to seed a new rule. useEffect(() => { if (!active) return; @@ -47,7 +30,6 @@ export function AutomationsPage({ active = true }: { active?: boolean } = {}) { active={active} pendingDraft={pendingDraft} onDraftConsumed={() => setPendingDraft(null)} - missionsEnabled={missionsEnabled} onOpenTemplates={() => navigate("/automations/templates")} /> </div> diff --git a/apps/desktop/src/renderer/components/automations/AutomationsTemplatesPage.tsx b/apps/desktop/src/renderer/components/automations/AutomationsTemplatesPage.tsx index b45d9d9b6..6f98dbdda 100644 --- a/apps/desktop/src/renderer/components/automations/AutomationsTemplatesPage.tsx +++ b/apps/desktop/src/renderer/components/automations/AutomationsTemplatesPage.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ArrowLeft } from "@phosphor-icons/react"; import type { AutomationRuleDraft } from "../../../shared/types"; @@ -7,23 +6,7 @@ import { TemplatesTab } from "./TemplatesTab"; export function AutomationsTemplatesPage({ active = true }: { active?: boolean } = {}) { const navigate = useNavigate(); - const [missionsEnabled, setMissionsEnabled] = useState(true); - - useEffect(() => { - if (!active) return; - let cancelled = false; - window.ade.app.getInfo().then( - (info) => { - if (!cancelled) setMissionsEnabled(!info.isPackaged); - }, - () => { - if (!cancelled) setMissionsEnabled(true); - }, - ); - return () => { - cancelled = true; - }; - }, [active]); + void active; return ( <div className="flex h-full w-full flex-col overflow-hidden bg-bg text-fg" data-testid="automations-templates-page"> @@ -40,7 +23,6 @@ export function AutomationsTemplatesPage({ active = true }: { active?: boolean } <div className="flex-1 min-h-0 overflow-hidden"> <TemplatesTab - missionsEnabled={missionsEnabled} onUseTemplate={(draft) => { navigate("/automations", { state: { draft: draft as AutomationRuleDraft } }); }} diff --git a/apps/desktop/src/renderer/components/automations/RuleHistoryPanel.tsx b/apps/desktop/src/renderer/components/automations/RuleHistoryPanel.tsx index 4fed5b7a0..87a1d0a74 100644 --- a/apps/desktop/src/renderer/components/automations/RuleHistoryPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/RuleHistoryPanel.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { ArrowClockwise } from "@phosphor-icons/react"; import type { AutomationRun, AutomationRunDetail } from "../../../shared/types"; import { Button } from "../ui/Button"; @@ -16,7 +15,6 @@ export function RuleHistoryPanel({ automationId: string; ruleName: string; }) { - const navigate = useNavigate(); const [runs, setRuns] = useState<AutomationRun[]>([]); const [loading, setLoading] = useState(false); const [selectedRunId, setSelectedRunId] = useState<string | null>(null); @@ -128,7 +126,6 @@ export function RuleHistoryPanel({ <RunDetailPanel detail={detail} loading={detailLoading} - onOpenMission={(missionId) => navigate(`/missions?missionId=${encodeURIComponent(missionId)}`)} /> </div> </div> diff --git a/apps/desktop/src/renderer/components/automations/RulesTab.tsx b/apps/desktop/src/renderer/components/automations/RulesTab.tsx index a62ec08f3..9a388ec16 100644 --- a/apps/desktop/src/renderer/components/automations/RulesTab.tsx +++ b/apps/desktop/src/renderer/components/automations/RulesTab.tsx @@ -44,9 +44,7 @@ function createBlankDraft(): AutomationRuleDraft { trigger: { type: "manual" }, execution: { kind: "agent-session", session: {} }, executor: { mode: "automation-bot" }, - modelConfig: { - orchestratorModel: { modelId: DEFAULT_MODEL_ID, thinkingLevel: "medium" }, - }, + modelConfig: { modelId: DEFAULT_MODEL_ID, thinkingLevel: "medium" }, permissionConfig: { providers: { claude: "full-auto", @@ -57,7 +55,7 @@ function createBlankDraft(): AutomationRuleDraft { }, prompt: "", reviewProfile: "quick", - toolPalette: ["repo", "git", "mission"], + toolPalette: ["repo", "git"], contextSources: [], guardrails: { maxDurationMin: 20 }, outputs: { disposition: "comment-only", createArtifact: true }, @@ -128,13 +126,6 @@ function toDraftFromRule(rule: AutomationRuleSummary): AutomationRuleDraft { ...(action.sessionTitle ? { sessionTitle: action.sessionTitle } : {}), } as any; } - if (action.type === "launch-mission") { - return { - type: action.type, - ...runtimeActionFields(action), - ...(action.sessionTitle ? { missionTitle: action.sessionTitle } : {}), - } as any; - } return { type: action.type, ...runtimeActionFields(action) } as any; }); return { @@ -175,13 +166,12 @@ function primaryTriggerLabel(rule: AutomationRuleSummary): string { } function executionLabel(rule: AutomationRuleSummary): string { - if (rule.execution?.kind === "mission") return "Mission"; if (rule.execution?.kind === "built-in") return "Built-in"; return "Agent session"; } function modeSummary(rule: AutomationRuleSummary): string { - return [rule.mode, rule.reviewProfile, rule.modelConfig?.orchestratorModel.modelId ?? null].filter(Boolean).join(" · "); + return [rule.mode, rule.reviewProfile, rule.modelConfig?.modelId ?? null].filter(Boolean).join(" · "); } function RuleListRow({ @@ -312,13 +302,11 @@ export function RulesTab({ pendingDraft, onDraftConsumed, onOpenTemplates, - missionsEnabled, }: { active?: boolean; pendingDraft: AutomationRuleDraft | null; onDraftConsumed: () => void; onOpenTemplates: () => void; - missionsEnabled: boolean; }) { const [detailView, setDetailView] = useState<DetailView>("editor"); const [rules, setRules] = useState<AutomationRuleSummary[]>([]); @@ -685,7 +673,6 @@ export function RulesTab({ setDraft={setDraft} lanes={lanes.map((lane) => ({ id: lane.id, name: lane.name }))} suites={suites} - missionsEnabled={missionsEnabled} issues={issues} requiredConfirmations={requiredConfirmations} acceptedConfirmations={acceptedConfirmations} @@ -707,7 +694,7 @@ export function RulesTab({ <div className={cn(cardCls, "max-w-md text-center")}> <div className="text-[17px] font-semibold text-fg">Create an automation</div> <div className="mt-2 text-sm leading-relaxed text-muted-fg/70"> - Start with a schedule or a product event, then tell ADE whether it should run a built-in task, send a prompt to an automation chat thread, or launch a mission. + Start with a schedule or a product event, then tell ADE whether it should run a built-in task or send a prompt to an automation chat thread. </div> <div className="mt-4 flex justify-center gap-2"> <Button size="sm" variant="primary" onClick={createRule}> diff --git a/apps/desktop/src/renderer/components/automations/TemplatesTab.tsx b/apps/desktop/src/renderer/components/automations/TemplatesTab.tsx index b9d62c54e..5632722d6 100644 --- a/apps/desktop/src/renderer/components/automations/TemplatesTab.tsx +++ b/apps/desktop/src/renderer/components/automations/TemplatesTab.tsx @@ -16,17 +16,13 @@ import { TemplateCard, type AutomationTemplate } from "./components/TemplateCard type TemplateDefinition = AutomationTemplate & { draft: Omit<AutomationRuleDraft, "id"> }; const DEFAULT_MODEL = { - orchestratorModel: { - modelId: "anthropic/claude-sonnet-4-6" as const, - thinkingLevel: "medium" as const, - }, + modelId: "anthropic/claude-sonnet-4-6" as const, + thinkingLevel: "medium" as const, }; const DEFAULT_MODEL_HIGH = { - orchestratorModel: { - modelId: "anthropic/claude-sonnet-4-6" as const, - thinkingLevel: "high" as const, - }, + modelId: "anthropic/claude-sonnet-4-6" as const, + thinkingLevel: "high" as const, }; const BASE_DRAFT: Pick< @@ -198,7 +194,7 @@ const TEMPLATES: TemplateDefinition[] = [ id: "nightly-test-sweep", name: "Nightly test sweep", description: - "Run the built-in test suite on a cron. No chat thread, no mission — just deterministic checks against main.", + "Run the built-in test suite on a cron. No chat thread, just deterministic checks against main.", triggerType: "schedule", actionSummary: "built-in · run-tests", icon: TestTube, @@ -314,10 +310,8 @@ const TEMPLATES: TemplateDefinition[] = [ export function TemplatesTab({ onUseTemplate, - missionsEnabled, }: { onUseTemplate: (draft: Omit<AutomationRuleDraft, "id">) => void; - missionsEnabled: boolean; }) { return ( <div className="h-full overflow-y-auto px-6 py-6" style={{ background: "#0F0D14" }}> @@ -330,18 +324,13 @@ export function TemplatesTab({ </div> <div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> - {TEMPLATES.map((template) => { - const disabled = template.draft.execution?.kind === "mission" && !missionsEnabled; - return ( - <TemplateCard - key={template.id} - template={template} - disabled={disabled} - disabledReason={disabled ? "Mission automations are coming soon in production builds." : undefined} - onUse={() => onUseTemplate(template.draft)} - /> - ); - })} + {TEMPLATES.map((template) => ( + <TemplateCard + key={template.id} + template={template} + onUse={() => onUseTemplate(template.draft)} + /> + ))} </div> </div> </div> diff --git a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts index 8ae89aedf..61a95870a 100644 --- a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts +++ b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts @@ -1260,138 +1260,6 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ ], }, - // --------------------------------------------------------------------------- - // mission - // --------------------------------------------------------------------------- - { - domain: "mission", - action: "list", - label: "List missions", - description: "Return missions for the project, optionally filtered by status or lane.", - params: [ - { name: "status", type: "string", description: "MissionStatus or 'active'." }, - { name: "laneId", type: "string", placeholder: "{{trigger.lane.id}}" }, - { name: "limit", type: "number" }, - { name: "includeArchived", type: "boolean" }, - ], - }, - { - domain: "mission", - action: "get", - label: "Get mission detail", - description: "Return the full detail (steps + events + artifacts + interventions) for a mission.", - params: [{ name: "missionId", type: "string", required: true }], - }, - { - domain: "mission", - action: "create", - label: "Create mission", - description: "Create a new mission with a prompt and optional launch settings.", - params: [ - { name: "prompt", type: "string", required: true }, - { name: "title", type: "string" }, - { name: "laneId", type: "string", placeholder: "{{trigger.lane.id}}" }, - { name: "priority", type: "enum", enumValues: ["urgent", "high", "normal", "low"] }, - { name: "executionMode", type: "enum", enumValues: ["local", "relay"] }, - { name: "targetMachineId", type: "string" }, - { name: "plannerEngine", type: "enum", enumValues: ["auto", "claude_cli", "codex_cli"] }, - { name: "planningTimeoutMs", type: "number" }, - { name: "autostart", type: "boolean" }, - { name: "launchMode", type: "enum", enumValues: ["autopilot", "manual"] }, - { name: "autopilotExecutor", type: "string" }, - { name: "executionPolicy", type: "json" }, - { name: "recoveryLoop", type: "json" }, - { name: "modelConfig", type: "json" }, - { name: "teamRuntime", type: "json" }, - { name: "agentRuntime", type: "json" }, - { name: "employeeAgentId", type: "string" }, - { name: "phaseProfileId", type: "string" }, - { name: "phaseOverride", type: "json" }, - { name: "permissionConfig", type: "json" }, - ], - }, - { - domain: "mission", - action: "update", - label: "Update mission", - description: "Patch the editable fields on a mission.", - params: [ - { name: "missionId", type: "string", required: true }, - { name: "title", type: "string" }, - { name: "prompt", type: "string" }, - { name: "laneId", type: "string", placeholder: "{{trigger.lane.id}}" }, - { name: "status", type: "string" }, - { name: "priority", type: "enum", enumValues: ["urgent", "high", "normal", "low"] }, - { name: "executionMode", type: "enum", enumValues: ["local", "relay"] }, - { name: "targetMachineId", type: "string" }, - { name: "outcomeSummary", type: "string" }, - { name: "lastError", type: "string" }, - ], - }, - { - domain: "mission", - action: "delete", - label: "Delete mission", - description: "Permanently delete a mission and its events.", - params: [{ name: "missionId", type: "string", required: true }], - }, - { - domain: "mission", - action: "archive", - label: "Archive mission", - description: "Archive a completed mission so it disappears from the active list.", - params: [{ name: "missionId", type: "string", required: true }], - }, - { - domain: "mission", - action: "addIntervention", - label: "Add mission intervention", - description: "Open an intervention against a mission (approval needed, manual input, etc.).", - params: [ - { name: "missionId", type: "string", required: true }, - { - name: "interventionType", - type: "enum", - required: true, - enumValues: [ - "approval_required", - "manual_input", - "conflict", - "policy_block", - "failed_step", - "orchestrator_escalation", - "budget_limit_reached", - "provider_unreachable", - "unrecoverable_error", - "phase_approval", - ], - }, - { name: "title", type: "string", required: true }, - { name: "body", type: "string", required: true }, - { name: "requestedAction", type: "string" }, - { name: "laneId", type: "string", placeholder: "{{trigger.lane.id}}" }, - { name: "metadata", type: "json" }, - { name: "pauseMission", type: "boolean" }, - ], - }, - { - domain: "mission", - action: "resolveIntervention", - label: "Resolve mission intervention", - description: "Close out an open intervention with a status and optional resolution kind.", - params: [ - { name: "missionId", type: "string", required: true }, - { name: "interventionId", type: "string", required: true }, - { name: "status", type: "enum", required: true, enumValues: ["resolved", "dismissed"] }, - { name: "note", type: "string" }, - { - name: "resolutionKind", - type: "enum", - enumValues: ["answer_provided", "accept_defaults", "skip_question", "cancel_run"], - }, - ], - }, - // --------------------------------------------------------------------------- // automations // --------------------------------------------------------------------------- @@ -1825,67 +1693,6 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ params: [], }, - // --------------------------------------------------------------------------- - // orchestrator - // --------------------------------------------------------------------------- - { domain: "orchestrator", action: "cancelRunGracefully", label: "Cancel orchestrator run gracefully", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator", action: "finalizeRun", label: "Finalize orchestrator run", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator", action: "getMissionMetrics", label: "Get mission metrics", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator", action: "getTeamMembers", label: "Get team members", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator", action: "getThreadMessages", label: "Get thread messages", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator", action: "getWorkerStates", label: "Get worker states", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator", action: "listChatThreads", label: "List orchestrator chat threads", description: "Advanced action — pass arguments as JSON.", params: [] }, - { - domain: "orchestrator", - action: "startMissionRun", - label: "Start mission run", - description: "Launch a mission run via the AI orchestrator.", - params: [], - }, - { - domain: "orchestrator", - action: "steerMission", - label: "Steer mission", - description: "Send a steering directive to a running mission.", - params: [ - { name: "missionId", type: "string", required: true }, - { name: "directive", type: "string", required: true, description: "Free-text steering message." }, - { name: "priority", type: "enum", required: true, enumValues: ["suggestion", "instruction", "override"] }, - { name: "targetStepKey", type: "string" }, - { name: "interventionId", type: "string" }, - { - name: "resolutionKind", - type: "enum", - enumValues: ["answer_provided", "accept_defaults", "skip_question", "cancel_run"], - }, - ], - }, - - // --------------------------------------------------------------------------- - // orchestrator_core - // --------------------------------------------------------------------------- - { domain: "orchestrator_core", action: "addReflection", label: "Add reflection", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "addSteps", label: "Add steps", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "appendRuntimeEvent", label: "Append runtime event", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "appendTimelineEvent", label: "Append timeline event", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "completeAttempt", label: "Complete attempt", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "createHandoff", label: "Create handoff", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "emitRuntimeUpdate", label: "Emit runtime update", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "getRunGraph", label: "Get run graph", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "listAttempts", label: "List attempts", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "listRetrospectivePatternStats", label: "List retrospective pattern stats", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "listRetrospectiveTrends", label: "List retrospective trends", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "listRetrospectives", label: "List retrospectives", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "listRuns", label: "List runs", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "listTimeline", label: "List timeline", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "pauseRun", label: "Pause run", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "resumeRun", label: "Resume run", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "skipStep", label: "Skip step", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "startReadyAutopilotAttempts", label: "Start ready autopilot attempts", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "supersedeStep", label: "Supersede step", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "updateStepDependencies", label: "Update step dependencies", description: "Advanced action — pass arguments as JSON.", params: [] }, - { domain: "orchestrator_core", action: "updateStepMetadata", label: "Update step metadata", description: "Advanced action — pass arguments as JSON.", params: [] }, - // --------------------------------------------------------------------------- // cto_state // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx index d0fb27ea7..680ab84a8 100644 --- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx @@ -37,7 +37,7 @@ import type { AutomationTrigger, TestSuiteDefinition, } from "../../../../shared/types"; -import { ModelSelector } from "../../missions/ModelSelector"; +import { ModelSelector } from "../../shared/ModelSelector"; import { Button } from "../../ui/Button"; import { Chip } from "../../ui/Chip"; import { cn } from "../../ui/cn"; @@ -150,7 +150,6 @@ const TOOL_FAMILIES: Array<{ value: AutomationToolFamily; label: string }> = [ { value: "github", label: "GitHub" }, { value: "linear", label: "Linear" }, { value: "browser", label: "Browser" }, - { value: "mission", label: "Mission" }, ]; const OUTPUT_DISPOSITIONS: Array<{ value: AutomationOutputDisposition; label: string }> = [ @@ -387,11 +386,6 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { sessionTitle: execution.session?.title ?? "", codexFastMode: execution.session?.codexFastMode === true, }); - } else if (execution?.kind === "mission") { - rows.push({ - kind: "launch-mission", - missionTitle: execution.mission?.title ?? "", - }); } else if (execution?.kind === "built-in") { for (const action of execution.builtIn?.actions ?? []) { if (action.type === "create-lane") { @@ -420,8 +414,6 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { permissionConfig: action.permissionConfig, ...actionRuntimeOptions(action), }); - } else if (action.type === "launch-mission") { - rows.push({ kind: "launch-mission", missionTitle: action.sessionTitle ?? "", ...actionRuntimeOptions(action) }); } } } @@ -433,7 +425,6 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue ? rows.map((row) => stripActionTargetLaneId(row) as ActionRowValue) : rows; const soloAgent = rowsForSave.length === 1 && rowsForSave[0]!.kind === "agent-session" && !rowHasRuntimeOptions(rowsForSave[0]!); - const soloMission = rowsForSave.length === 1 && rowsForSave[0]!.kind === "launch-mission" && !rowHasRuntimeOptions(rowsForSave[0]!); if (soloAgent) { const first = rowsForSave[0]!; @@ -453,7 +444,7 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue ...(first.codexFastMode === true ? { codexFastMode: true } : {}), }, }, - ...(first.modelConfig ? { modelConfig: { orchestratorModel: first.modelConfig } } : {}), + ...(first.modelConfig ? { modelConfig: first.modelConfig } : {}), ...(first.permissionConfig ? { permissionConfig: first.permissionConfig } : {}), prompt: first.prompt ?? "", actions: [], @@ -461,20 +452,6 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue }; } - if (soloMission) { - const first = rowsForSave[0]!; - return { - ...draft, - execution: { - ...(draft.execution ?? { kind: "mission" }), - kind: "mission", - mission: { ...(draft.execution?.kind === "mission" ? draft.execution.mission : {}), title: first.missionTitle || null }, - }, - actions: [], - legacyActions: [], - }; - } - const builtInActions: AutomationAction[] = rowsForSave.map((row) => rowToAutomationAction(row)); const legacyDraftActions: AutomationRuleDraft["actions"] = builtInActions .map((action) => automationActionToDraftAction(action)) @@ -530,12 +507,6 @@ function rowToAutomationAction(row: ActionRowValue): AutomationAction { ...(row.prompt ? { prompt: row.prompt } : {}), ...(row.sessionTitle ? { sessionTitle: row.sessionTitle } : {}), }; - case "launch-mission": - return { - type: "launch-mission", - ...rowRuntimeOptions(row), - ...(row.missionTitle ? { sessionTitle: row.missionTitle } : {}), - }; } } @@ -578,12 +549,6 @@ function automationActionToDraftAction( ...(action.prompt ? { prompt: action.prompt } : {}), ...(action.sessionTitle ? { sessionTitle: action.sessionTitle } : {}), }; - case "launch-mission": - return { - type: "launch-mission", - ...rowRuntimeOptions(actionToRow(action)), - ...(action.sessionTitle ? { missionTitle: action.sessionTitle } : {}), - }; case "lane-setup": // Synthetic action emitted by the runtime when execution.laneMode is // "create"; never authored by the user, so it has no draft form. @@ -615,7 +580,6 @@ export function RuleEditorPanel({ setDraft: (draft: AutomationRuleDraft) => void; lanes: Array<{ id: string; name: string }>; suites: TestSuiteDefinition[]; - missionsEnabled: boolean; issues: AutomationDraftIssue[]; requiredConfirmations: AutomationDraftConfirmationRequirement[]; acceptedConfirmations: Set<string>; @@ -636,10 +600,10 @@ export function RuleEditorPanel({ const actionRows = useMemo(() => draftToActionRows(draft), [draft]); const includeProjectContext = computeIncludeProjectContext(draft); - const modelValue = draft.modelConfig?.orchestratorModel ?? { modelId: DEFAULT_MODEL_ID, thinkingLevel: "medium" as const }; + const modelValue = draft.modelConfig ?? { modelId: DEFAULT_MODEL_ID, thinkingLevel: "medium" as const }; const outputs = draft.outputs ?? { disposition: "comment-only" as const, createArtifact: true }; const verification = draft.verification ?? { verifyBeforePublish: false, mode: "intervention" as const }; - const toolPalette: AutomationToolFamily[] = draft.toolPalette ?? ["repo", "mission"]; + const toolPalette: AutomationToolFamily[] = draft.toolPalette ?? ["repo"]; const permissionMeta = permissionControlsForModel(modelValue.modelId); const currentPermission = permissionMeta ? draft.permissionConfig?.providers?.[permissionMeta.key] ?? "" @@ -994,7 +958,7 @@ export function RuleEditorPanel({ onChange={(next) => setDraft({ ...draft, - modelConfig: { orchestratorModel: next }, + modelConfig: next, }) } onOpenAiSettings={openAiSettings} diff --git a/apps/desktop/src/renderer/components/automations/components/RunDetailPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RunDetailPanel.tsx index 5fc022919..8ee3e5dcc 100644 --- a/apps/desktop/src/renderer/components/automations/components/RunDetailPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RunDetailPanel.tsx @@ -1,7 +1,6 @@ -import { ArrowSquareOut, GitBranch } from "@phosphor-icons/react"; +import { GitBranch } from "@phosphor-icons/react"; import type { AutomationRunDetail } from "../../../../shared/types"; import { AgentChatPane } from "../../chat/AgentChatPane"; -import { Button } from "../../ui/Button"; import { Chip } from "../../ui/Chip"; import { cn } from "../../ui/cn"; import { statusToneAutomation as statusTone } from "../../../lib/format"; @@ -25,11 +24,9 @@ function MetaCard({ export function RunDetailPanel({ detail, loading, - onOpenMission, }: { detail: AutomationRunDetail | null; loading: boolean; - onOpenMission?: (missionId: string) => void; }) { if (loading) { return <div className="p-5 text-sm text-muted-fg/60">Loading run detail...</div>; @@ -62,13 +59,6 @@ export function RunDetailPanel({ <div className="mt-2 text-sm text-error">{detail.run.errorMessage}</div> ) : null} </div> - - {detail.run.missionId && onOpenMission ? ( - <Button size="sm" variant="outline" onClick={() => onOpenMission(detail.run.missionId!)}> - <ArrowSquareOut size={12} weight="regular" /> - Open mission - </Button> - ) : null} </div> </section> @@ -79,7 +69,6 @@ export function RunDetailPanel({ <MetaCard label="Started" value={detail.run.startedAt} /> <MetaCard label="Ended" value={detail.run.endedAt ?? "still running"} /> <MetaCard label="Automation" value={detail.run.automationId} /> - <MetaCard label="Mission" value={detail.run.missionId ?? "none"} /> <MetaCard label="Chat session" value={detail.run.chatSessionId ?? "none"} /> </section> @@ -110,15 +99,6 @@ export function RunDetailPanel({ </section> ) : null} - {detail.run.missionId && !detail.chatSession ? ( - <section className={cardCls}> - <div className="text-sm font-semibold text-fg">Mission-backed run</div> - <div className="mt-1 text-sm text-muted-fg/70"> - This automation launched a mission instead of an automation chat thread. Open the mission to inspect the live transcript, steps, and artifacts. - </div> - </section> - ) : null} - {detail.actions.length > 0 ? ( <section className={cardCls}> <div className="text-sm font-semibold text-fg">Action output</div> diff --git a/apps/desktop/src/renderer/components/automations/components/RunHistoryRow.tsx b/apps/desktop/src/renderer/components/automations/components/RunHistoryRow.tsx index ba3655f87..78c0c54ee 100644 --- a/apps/desktop/src/renderer/components/automations/components/RunHistoryRow.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RunHistoryRow.tsx @@ -5,7 +5,6 @@ import { formatDate, formatDurationMs, statusToneAutomation as statusTone } from function summarizeExecution(run: AutomationRun): string { if (run.executionKind === "agent-session") return "Agent session"; - if (run.executionKind === "mission") return "Mission"; return `${Math.max(1, run.actionsTotal)} built-in task${run.actionsTotal === 1 ? "" : "s"}`; } @@ -47,7 +46,6 @@ export function RunHistoryRow({ <span>{formatDurationMs(durationMs)}</span> <span>{summarizeExecution(run)}</span> {run.chatSessionId ? <span>{run.chatSessionId.slice(0, 8)}</span> : null} - {!run.chatSessionId && run.missionId ? <span>{run.missionId.slice(0, 8)}</span> : null} </div> {run.summary ? ( diff --git a/apps/desktop/src/renderer/components/automations/permissionControls.ts b/apps/desktop/src/renderer/components/automations/permissionControls.ts index c1d250a57..58aebf172 100644 --- a/apps/desktop/src/renderer/components/automations/permissionControls.ts +++ b/apps/desktop/src/renderer/components/automations/permissionControls.ts @@ -1,7 +1,9 @@ import { resolveModelDescriptor } from "../../../shared/modelRegistry"; -import type { AgentChatPermissionMode, MissionPermissionConfig } from "../../../shared/types"; +import type { AgentChatPermissionMode, AutomationRuleDraft } from "../../../shared/types"; import { familyToPermissionKey, getPermissionOptions } from "../shared/permissionOptions"; +type AutomationPermissionConfig = NonNullable<AutomationRuleDraft["permissionConfig"]>; + export function permissionControlsForModel(modelId: string) { const descriptor = resolveModelDescriptor(modelId); if (!descriptor) return null; @@ -22,23 +24,23 @@ function codexSandboxForMode(mode: AgentChatPermissionMode) { } export function patchPermissionConfig( - permissionConfig: MissionPermissionConfig | undefined, + permissionConfig: AutomationPermissionConfig | undefined, modelId: string, rawMode: string, -): MissionPermissionConfig | undefined { +): AutomationPermissionConfig | undefined { const meta = permissionControlsForModel(modelId); if (!meta) return permissionConfig; const providers: Record<string, unknown> = { ...(permissionConfig?.providers ?? {}) }; if (!rawMode) { delete providers[meta.key]; if (meta.key === "codex") delete providers.codexSandbox; - return { ...(permissionConfig ?? {}), providers: providers as MissionPermissionConfig["providers"] }; + return { ...(permissionConfig ?? {}), providers: providers as AutomationPermissionConfig["providers"] }; } const mode = rawMode as AgentChatPermissionMode; return { ...(permissionConfig ?? {}), providers: { - ...(providers as MissionPermissionConfig["providers"]), + ...(providers as AutomationPermissionConfig["providers"]), [meta.key]: mode, ...(meta.key === "codex" && mode !== "config-toml" ? { codexSandbox: codexSandboxForMode(mode) } : {}), }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 9fb6f588f..f7c27f961 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1219,11 +1219,11 @@ describe("AgentChatComposer", () => { renderComposer({ draft: "", turnActive: true, - messagePlaceholder: "Message the mission orchestrator...", + messagePlaceholder: "Message the active coordinator...", }); const textarea = screen.getByRole("textbox", { - name: "Steer active turn: Message the mission orchestrator", + name: "Steer active turn: Message the active coordinator", }) as HTMLTextAreaElement; expect(textarea.placeholder).toBe("Steer the active turn..."); }); @@ -1232,7 +1232,7 @@ describe("AgentChatComposer", () => { renderComposer({ draft: "", turnActive: true, - messagePlaceholder: "Message this mission worker...", + messagePlaceholder: "Message this worker...", iosElementContextItems: [ { kind: "ios_element", @@ -1248,7 +1248,7 @@ describe("AgentChatComposer", () => { }); expect(screen.getByRole("textbox", { - name: "Steer active turn: Message this mission worker", + name: "Steer active turn: Message this worker", })).toBeTruthy(); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 2a8a82521..985b3e51c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -172,24 +172,24 @@ describe("AgentChatMessageList operator navigation suggestions", () => { expect(screen.getByTestId("location").textContent).toBe("/work?sessionId=chat-1::null"); }); - it("renders mission suggestions from tool results and navigates by deeplink", () => { + it("renders lane suggestions from tool results and navigates by deeplink", () => { renderMessageList([ { sessionId: "session-1", timestamp: "2026-03-17T10:00:00.000Z", event: { type: "tool_result", - tool: "startMission", + tool: "openLane", itemId: "tool-2", status: "completed", result: { success: true, navigationSuggestions: [ { - surface: "missions", - label: "Open mission", - href: "/missions?missionId=mission-1", - missionId: "mission-1", + surface: "lanes", + label: "Open lane", + href: "/lanes?laneId=lane-1", + laneId: "lane-1", }, ], }, @@ -198,9 +198,9 @@ describe("AgentChatMessageList operator navigation suggestions", () => { ]); fireEvent.click(screen.getByRole("button", { name: /Tool calls/ })); - fireEvent.click(screen.getByRole("button", { name: "Open mission" })); + fireEvent.click(screen.getByRole("button", { name: "Open lane" })); - expect(screen.getByTestId("location").textContent).toBe("/missions?missionId=mission-1::null"); + expect(screen.getByTestId("location").textContent).toBe("/lanes?laneId=lane-1::null"); }); }); @@ -1697,7 +1697,7 @@ describe("AgentChatMessageList transcript rendering", () => { event: { type: "subagent_started", taskId: "bg-1", - description: "Check mission thread renderer", + description: "Check chat thread renderer", turnId: "turn-7", background: true, }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index d846b97e2..241f63f4b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -96,7 +96,7 @@ import { CodexImageGenerationCard } from "./codex/CodexImageGenerationCard"; import { CodexImageViewLine } from "./codex/CodexImageViewLine"; import { CodexContextCompactionChip } from "./codex/CodexContextCompactionChip"; -const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); +const NAVIGATION_SURFACES = new Set(["work", "lanes", "cto"]); type PendingInputResolution = Extract<AgentChatEvent, { type: "pending_input_resolved" }>["resolution"]; type WorkspacePathLocation = { path: string; @@ -114,7 +114,6 @@ function readOperatorNavigationSuggestion(value: unknown): OperatorNavigationSug const result: OperatorNavigationSuggestion = { surface: surface as OperatorNavigationSuggestion["surface"], href, label }; if (typeof record.laneId === "string") result.laneId = record.laneId; if (typeof record.sessionId === "string") result.sessionId = record.sessionId; - if (typeof record.missionId === "string") result.missionId = record.missionId; return result; } @@ -1957,9 +1956,7 @@ function renderEvent( } ) { const event = envelope.event; - const activeTurnMotionEnabled = Boolean(options?.turnActive) - && options?.surfaceMode !== "mission-thread" - && options?.surfaceMode !== "mission-feed"; + const activeTurnMotionEnabled = Boolean(options?.turnActive); /* ── User message ── */ if (event.type === "user_message") { @@ -3397,9 +3394,7 @@ const EventRow = React.memo(function EventRow({ }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded - && Boolean(isLatestWorkLog) - && surfaceMode !== "mission-thread" - && surfaceMode !== "mission-feed"; + && Boolean(isLatestWorkLog); return ( <div className="min-w-0 max-w-full space-y-3 overflow-hidden"> {showTurnDivider ? ( @@ -4283,9 +4278,7 @@ function AgentChatMessageListMain({ }, [shouldVirtualize, endIndex, groupedRows.length, rowHeight, timelineRowGapPx]); const streamingIndicatorAnimated = showStreamingIndicator - && !sessionEnded - && surfaceMode !== "mission-thread" - && surfaceMode !== "mission-feed"; + && !sessionEnded; const streamingIndicator = showStreamingIndicator && !sessionEnded ? ( <motion.div className="w-fit max-w-[min(100%,70ch)] pt-3 pb-2" diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c089fd3d3..d2022dc9d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -123,7 +123,6 @@ import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; -import { rewriteMissionControlTextToolEvents } from "./missionControlTextTools"; import { buildAutomaticMacosVmContextForPrompt, createAppControlContextInstanceId, @@ -417,7 +416,7 @@ function getExecutionModeOptions(model: ModelDescriptor | null | undefined): Exe value: "parallel", label: "Parallel", summary: "Droid delegates", - helper: "Tell Droid to use available delegation or mission-style tools for independent subtasks, then reconcile the result.", + helper: "Tell Droid to use available delegation tools for independent subtasks, then reconcile the result.", accent: "#10B981", }, ]; @@ -2268,9 +2267,7 @@ export function AgentChatPane({ ? [...selectedEvents, optimisticOutgoingMessage.envelope] : selectedEvents; const renderableEvents = baseEvents.filter((envelope) => !envelope.event.type.startsWith("subagent.")); - const displayEvents = presentation?.rewriteMissionControlTextTools === true || presentation?.mode === "mission-thread" - ? rewriteMissionControlTextToolEvents(renderableEvents) - : renderableEvents; + const displayEvents = renderableEvents; const promotedTurnId = selectedSession?.cursorPromotedTurnId; const cloudAgentId = selectedSession?.cursorCloudAgentId; if (!promotedTurnId || !cloudAgentId) return displayEvents; @@ -2299,7 +2296,7 @@ export function AgentChatPane({ }, }; return [...displayEvents.slice(0, insertAt), synthetic, ...displayEvents.slice(insertAt)]; - }, [optimisticOutgoingMessage, presentation?.mode, presentation?.rewriteMissionControlTextTools, selectedEvents, selectedSession?.cursorCloudAgentId, selectedSession?.cursorPromotedTurnId, selectedSessionId]); + }, [optimisticOutgoingMessage, selectedEvents, selectedSession?.cursorCloudAgentId, selectedSession?.cursorPromotedTurnId, selectedSessionId]); const selectedCodexGoal = useMemo<CodexThreadGoal | null>(() => { let goalFromEvents: CodexThreadGoal | null = null; let sawGoalEvent = false; @@ -6662,7 +6659,6 @@ export function AgentChatPane({ ) : null} {chatTerminalVisible ? <ChatTerminalToggle open={terminalDrawerOpen} onToggle={() => setTerminalDrawerOpen((v) => !v)} /> : null} {selectedSession?.provider === "codex" - && selectedSession.surface !== "mission" && selectedSessionId && selectedSession.threadId ? ( <CodexOpenInCliButton diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx index c11e07905..456cb2587 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx @@ -522,12 +522,6 @@ export const ChatAttachmentTray = forwardRef<HTMLDivElement, ChatAttachmentTrayP case "resolver": chipTone = "border-orange-400/18 bg-orange-500/10 text-orange-100"; break; - case "mission-feed": - chipTone = "border-emerald-400/18 bg-emerald-500/10 text-emerald-100"; - break; - case "mission-thread": - chipTone = "border-sky-400/18 bg-sky-500/10 text-sky-100"; - break; default: chipTone = "border-[color:color-mix(in_srgb,var(--chat-accent)_22%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_10%,transparent)] text-fg/82"; break; diff --git a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx index 7adc879e2..c2146f3f9 100644 --- a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx @@ -16,7 +16,7 @@ import { getToolMeta } from "./chatToolAppearance"; import { replaceInternalToolNames } from "./toolPresentation"; import { openUrlInAdeBrowser } from "../../lib/openExternal"; -const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); +const NAVIGATION_SURFACES = new Set(["work", "lanes", "cto"]); const WORK_LOG_DETAIL_TRUNCATE_LIMIT = 500; function readOperatorNavigationSuggestion(value: unknown): OperatorNavigationSuggestion | null { @@ -32,7 +32,6 @@ function readOperatorNavigationSuggestion(value: unknown): OperatorNavigationSug label, ...(typeof record.laneId === "string" ? { laneId: record.laneId } : {}), ...(typeof record.sessionId === "string" ? { sessionId: record.sessionId } : {}), - ...(typeof record.missionId === "string" ? { missionId: record.missionId } : {}), }; } diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index 1b7e3484a..e51bd9518 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -34,7 +34,7 @@ describe("deriveChatSubagentSnapshots", () => { event: { type: "subagent_started", taskId: "task-running", - description: "Check mission transcript path", + description: "Check transcript path", }, }, ]; @@ -42,7 +42,7 @@ describe("deriveChatSubagentSnapshots", () => { expect(deriveChatSubagentSnapshots(events)).toEqual([ expect.objectContaining({ taskId: "task-running", - description: "Check mission transcript path", + description: "Check transcript path", status: "running", }), expect.objectContaining({ diff --git a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts index 69daa2c84..810217c66 100644 --- a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts +++ b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts @@ -2,11 +2,9 @@ import type { CSSProperties } from "react"; import type { ChatChromeTint } from "../../state/appStore"; import type { ChatSurfaceChipTone, ChatSurfaceMode } from "../../../shared/types"; -export const CHAT_SURFACE_ACCENTS: Record<ChatSurfaceMode, string> = { +export const CHAT_SURFACE_ACCENTS: Record<string, string> = { standard: "#71717A", resolver: "#F97316", - "mission-thread": "#38BDF8", - "mission-feed": "#22C55E", }; /// Per-provider chat-surface accent. Keeps Claude/Codex/etc. visually @@ -70,7 +68,7 @@ export function colorToRgba(value: string, alpha: number): string { export function resolveChatSurfaceAccent(mode: ChatSurfaceMode, accentColor?: string | null): string { const trimmed = accentColor?.trim(); - return trimmed?.length ? normalizeHex(trimmed) : CHAT_SURFACE_ACCENTS[mode]; + return trimmed?.length ? normalizeHex(trimmed) : CHAT_SURFACE_ACCENTS[mode] ?? CHAT_SURFACE_ACCENTS.standard; } /** Gray zinc accent — no per-runtime provider tint in chrome or bubbles. */ diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx index e8bd135a4..7c9422898 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx @@ -74,7 +74,6 @@ const TOOL_META: Record<string, ToolMeta> = { request_specialist: { label: "Specialist", icon: User, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.role ?? a.name ?? "") || null }, delegate_to_subagent: { label: "Delegate", icon: User, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.name ?? a.parentWorkerId ?? "") || null }, delegate_parallel: { label: "Delegate Batch", icon: User, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => `${Array.isArray(a.tasks) ? a.tasks.length : 0} task(s)` }, - read_mission_status: { label: "Mission Status", icon: MagnifyingGlass, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, get_worker_output: { label: "Worker Output", icon: FileCode, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent", getTarget: a => String(a.workerId ?? "") || null }, revise_plan: { label: "Revise Plan", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, retry_step: { label: "Retry", icon: ArrowSquareOut, badgeCls: "border-amber-400/25 bg-amber-400/12 text-amber-200", category: "meta", sourceTone: "warning", getTarget: a => String(a.workerId ?? "") || null }, diff --git a/apps/desktop/src/renderer/components/chat/missionControlTextTools.test.ts b/apps/desktop/src/renderer/components/chat/missionControlTextTools.test.ts deleted file mode 100644 index b81985fbb..000000000 --- a/apps/desktop/src/renderer/components/chat/missionControlTextTools.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentChatEventEnvelope } from "../../../shared/types"; -import { - extractMissionControlTextToolCalls, - rewriteMissionControlTextToolEvents, -} from "./missionControlTextTools"; - -describe("mission control text tool rewriting", () => { - it("extracts fenced mission tool calls", () => { - const calls = extractMissionControlTextToolCalls([ - "```json", - "// ade_spawn_worker", - "{\"name\":\"planner\",\"prompt\":\"Plan the work\"}", - "```", - ].join("\n")); - - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ - toolName: "spawn_worker", - args: { name: "planner", prompt: "Plan the work" }, - }); - }); - - it("extracts bare mission tool calls from legacy transcripts", () => { - const calls = extractMissionControlTextToolCalls([ - "// get_project_context", - "{\"runId\":\"run-1\",\"missionId\":\"mission-1\"}", - "", - "// spawn_worker", - "{\"name\":\"planner\",\"prompt\":\"Plan the work\",\"readOnly\":true}", - ].join("\n")); - - expect(calls.map((call) => call.toolName)).toEqual(["get_project_context", "spawn_worker"]); - expect(calls[1]?.args).toMatchObject({ name: "planner", readOnly: true }); - }); - - it("replaces transport-only text with normal tool-call events", () => { - const events: AgentChatEventEnvelope[] = [{ - sessionId: "chat-1", - timestamp: "2026-05-06T20:00:00.000Z", - event: { - type: "text", - text: [ - "```json", - "// get_project_context", - "{\"runId\":\"run-1\"}", - "```", - ].join("\n"), - turnId: "turn-1", - itemId: "item-1", - }, - }]; - - const rewritten = rewriteMissionControlTextToolEvents(events); - - expect(rewritten).toHaveLength(1); - expect(rewritten[0]?.event).toMatchObject({ - type: "tool_call", - tool: "get_project_context", - args: { runId: "run-1" }, - itemId: "mission-control-text:item-1:0", - turnId: "turn-1", - }); - }); - - it("preserves prose around a recovered tool call", () => { - const events: AgentChatEventEnvelope[] = [{ - sessionId: "chat-1", - timestamp: "2026-05-06T20:00:00.000Z", - event: { - type: "text", - text: [ - "I will start by reading context.", - "", - "```json", - "// get_project_context", - "{\"runId\":\"run-1\"}", - "```", - ].join("\n"), - turnId: "turn-1", - }, - }]; - - const rewritten = rewriteMissionControlTextToolEvents(events); - - expect(rewritten).toHaveLength(2); - expect(rewritten[0]?.event).toMatchObject({ - type: "text", - text: "I will start by reading context.", - }); - expect(rewritten[1]?.event).toMatchObject({ - type: "tool_call", - tool: "get_project_context", - }); - }); - - it("recovers mission tool calls split across text chunks in the same turn", () => { - const events: AgentChatEventEnvelope[] = [ - { - sessionId: "chat-1", - timestamp: "2026-05-06T20:00:00.000Z", - event: { - type: "text", - text: "// mark_step_complete\n", - turnId: "turn-1", - itemId: "item-1", - }, - }, - { - sessionId: "chat-1", - timestamp: "2026-05-06T20:00:00.001Z", - event: { - type: "text", - text: "{\"stepId\":\"step-1\",\"summary\":\"Done\"}", - turnId: "turn-1", - itemId: "item-2", - }, - }, - ]; - - const rewritten = rewriteMissionControlTextToolEvents(events); - - expect(rewritten).toHaveLength(1); - expect(rewritten[0]?.event).toMatchObject({ - type: "tool_call", - tool: "mark_step_complete", - args: { stepId: "step-1", summary: "Done" }, - turnId: "turn-1", - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/chat/missionControlTextTools.ts b/apps/desktop/src/renderer/components/chat/missionControlTextTools.ts deleted file mode 100644 index 964ef7529..000000000 --- a/apps/desktop/src/renderer/components/chat/missionControlTextTools.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { AgentChatEventEnvelope } from "../../../shared/types"; - -type RecoveredToolCall = { - toolName: string; - args: Record<string, unknown>; - start: number; - end: number; -}; - -const MISSION_CONTROL_TOOL_NAMES = new Set([ - "ask_user", - "assign_task", - "broadcast", - "check_finalization_status", - "complete_mission", - "create_task", - "delegate_parallel", - "delegate_to_subagent", - "fail_mission", - "get_budget_status", - "get_project_context", - "get_worker_output", - "insert_milestone", - "list_tasks", - "list_workers", - "mark_step_complete", - "mark_step_failed", - "message_worker", - "provision_lane", - "read_file", - "read_mission_state", - "read_mission_status", - "read_step_output", - "reflection_add", - "report_result", - "report_status", - "report_validation", - "request_specialist", - "request_user_input", - "retry_step", - "revise_plan", - "search_files", - "send_message", - "set_current_phase", - "skip_step", - "spawn_worker", - "stop_worker", - "transfer_lane", - "update_mission_state", - "update_task", - "update_tool_profiles", -]); - -function asRecord(value: unknown): Record<string, unknown> | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record<string, unknown>; -} - -export function normalizeMissionControlToolName(value: string): string { - let normalized = value.trim(); - normalized = normalized.replace(/^ade[_:.]/i, ""); - return normalized.trim(); -} - -export function isMissionControlToolName(value: string): boolean { - return MISSION_CONTROL_TOOL_NAMES.has(normalizeMissionControlToolName(value)); -} - -function parseJsonWithoutLineComments(value: string): unknown { - const body = value - .split(/\r?\n/) - .filter((line) => !line.trim().startsWith("//")) - .join("\n") - .trim(); - if (!body.length) return null; - return JSON.parse(body); -} - -function callsFromParsedToolBlock( - toolName: string, - parsed: unknown, - start: number, - end: number, -): RecoveredToolCall[] { - const normalizedToolName = normalizeMissionControlToolName(toolName); - if (/^(?:multi_tool_use\.)?parallel$/i.test(normalizedToolName)) { - const entries = Array.isArray(parsed) ? parsed : []; - return entries.flatMap((entry) => { - const raw = asRecord(entry); - const name = typeof raw?.name === "string" - ? raw.name - : typeof raw?.recipient_name === "string" - ? raw.recipient_name - : ""; - const args = asRecord(raw?.arguments ?? raw?.parameters ?? raw?.args) ?? {}; - const normalizedName = normalizeMissionControlToolName(name); - return normalizedName && isMissionControlToolName(normalizedName) - ? [{ toolName: normalizedName, args, start, end }] - : []; - }); - } - - const parsedRecord = asRecord(parsed); - if (normalizedToolName && parsedRecord && isMissionControlToolName(normalizedToolName)) { - return [{ toolName: normalizedToolName, args: parsedRecord, start, end }]; - } - - const embeddedName = typeof parsedRecord?.name === "string" - ? parsedRecord.name - : typeof parsedRecord?.recipient_name === "string" - ? parsedRecord.recipient_name - : ""; - const args = asRecord(parsedRecord?.arguments ?? parsedRecord?.parameters ?? parsedRecord?.args) ?? null; - const normalizedEmbeddedName = normalizeMissionControlToolName(embeddedName); - if (normalizedEmbeddedName && args && isMissionControlToolName(normalizedEmbeddedName)) { - return [{ toolName: normalizedEmbeddedName, args, start, end }]; - } - - return []; -} - -function findBalancedJson(value: string, startIndex: number): { json: string; end: number } | null { - const jsonStart = value.slice(startIndex).search(/[\[{]/); - if (jsonStart < 0) return null; - const start = startIndex + jsonStart; - const opener = value[start]; - const closer = opener === "{" ? "}" : "]"; - let depth = 0; - let inString = false; - let escaped = false; - - for (let i = start; i < value.length; i += 1) { - const ch = value[i]; - if (inString) { - if (escaped) { - escaped = false; - } else if (ch === "\\") { - escaped = true; - } else if (ch === "\"") { - inString = false; - } - continue; - } - - if (ch === "\"") { - inString = true; - continue; - } - if (ch === opener) { - depth += 1; - } else if (ch === closer) { - depth -= 1; - if (depth === 0) return { json: value.slice(start, i + 1), end: i + 1 }; - } - } - return null; -} - -export function extractMissionControlTextToolCalls(text: string): RecoveredToolCall[] { - const calls: RecoveredToolCall[] = []; - const fencePattern = /```(?:json|js|javascript)?\s*\n([\s\S]*?)```/gi; - let fenceMatch: RegExpExecArray | null; - while ((fenceMatch = fencePattern.exec(text)) !== null) { - const body = fenceMatch[1]?.trim() ?? ""; - if (!body) continue; - const label = body - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.startsWith("//")) - ?.replace(/^\/\/\s*/, "") - .trim() ?? ""; - if (!label) continue; - try { - const parsed = parseJsonWithoutLineComments(body); - calls.push(...callsFromParsedToolBlock(label, parsed, fenceMatch.index, fencePattern.lastIndex)); - } catch { - // Ignore malformed provider text; the original message will remain visible. - } - } - - if (calls.length > 0) return calls; - - const linePattern = /\/\/\s*([A-Za-z0-9_.:-]+)\s*\n/g; - let lineMatch: RegExpExecArray | null; - while ((lineMatch = linePattern.exec(text)) !== null) { - const label = lineMatch[1] ?? ""; - if (!isMissionControlToolName(label) && !/^(?:multi_tool_use\.)?parallel$/i.test(label)) continue; - const parsedJson = findBalancedJson(text, linePattern.lastIndex); - if (!parsedJson) continue; - try { - const parsed = JSON.parse(parsedJson.json); - calls.push(...callsFromParsedToolBlock(label, parsed, lineMatch.index, parsedJson.end)); - linePattern.lastIndex = parsedJson.end; - } catch { - // Leave malformed text untouched. - } - } - - return calls; -} - -function removeRanges(value: string, ranges: Array<{ start: number; end: number }>): string { - if (!ranges.length) return value; - const merged = [...ranges] - .sort((a, b) => a.start - b.start) - .reduce<Array<{ start: number; end: number }>>((acc, range) => { - const last = acc[acc.length - 1]; - if (!last || range.start > last.end) { - acc.push({ ...range }); - } else { - last.end = Math.max(last.end, range.end); - } - return acc; - }, []); - let output = ""; - let cursor = 0; - for (const range of merged) { - output += value.slice(cursor, range.start); - cursor = range.end; - } - output += value.slice(cursor); - return output.replace(/\n{3,}/g, "\n\n").trim(); -} - -export function rewriteMissionControlTextToolEvents( - events: AgentChatEventEnvelope[], -): AgentChatEventEnvelope[] { - const rewritten: AgentChatEventEnvelope[] = []; - - for (let index = 0; index < events.length; index += 1) { - const envelope = events[index]!; - if (envelope.event.type !== "text") { - rewritten.push(envelope); - continue; - } - - const textEvent = envelope.event; - const group = [envelope]; - let combinedText = textEvent.text; - let cursor = index + 1; - while (textEvent.turnId && cursor < events.length) { - const candidate = events[cursor]!; - if (candidate.event.type !== "text") break; - if (candidate.event.turnId !== textEvent.turnId) break; - if (candidate.event.runtime !== textEvent.runtime) break; - group.push(candidate); - combinedText += candidate.event.text; - cursor += 1; - } - - const calls = extractMissionControlTextToolCalls(combinedText); - if (!calls.length) { - rewritten.push(...group); - index = cursor - 1; - continue; - } - - const remainingText = removeRanges(combinedText, calls); - if (remainingText.length > 0) { - rewritten.push({ - ...envelope, - event: { - ...textEvent, - text: remainingText, - }, - }); - } - - calls.forEach((call, index) => { - const sourceItemId = textEvent.itemId ?? textEvent.messageId ?? `${envelope.timestamp}:${index}`; - rewritten.push({ - ...envelope, - event: { - type: "tool_call", - tool: call.toolName, - args: call.args, - itemId: `mission-control-text:${sourceItemId}:${index}`, - ...(textEvent.turnId ? { turnId: textEvent.turnId } : {}), - ...(textEvent.runtime ? { runtime: textEvent.runtime } : {}), - }, - }); - }); - index = cursor - 1; - } - - return rewritten; -} diff --git a/apps/desktop/src/renderer/components/chat/pendingInput.test.ts b/apps/desktop/src/renderer/components/chat/pendingInput.test.ts index 7a22ae766..10a2b6187 100644 --- a/apps/desktop/src/renderer/components/chat/pendingInput.test.ts +++ b/apps/desktop/src/renderer/components/chat/pendingInput.test.ts @@ -82,7 +82,7 @@ describe("derivePendingInputRequests", () => { const structuredRequest = { requestId: "req-1", itemId: "item-1", - source: "mission", + source: "ade", kind: "approval", title: "Approve deploy", description: "Deploy to staging", @@ -218,7 +218,7 @@ describe("derivePendingInputRequests", () => { expect(result[0]!.request.questions[0]!.id).toBe("response"); expect(result[0]!.request.questions[0]!.question).toBe("What color theme do you prefer?"); expect(result[0]!.request.questions[0]!.allowsFreeform).toBe(true); - expect(result[0]!.request.source).toBe("mission"); + expect(result[0]!.request.source).toBe("ade"); expect(result[0]!.request.turnId).toBe("turn-1"); }); @@ -637,7 +637,7 @@ describe("derivePendingInputRequests", () => { it("option value defaults to label when value is missing", () => { const structuredRequest = { requestId: "req-opt", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { @@ -667,7 +667,7 @@ describe("derivePendingInputRequests", () => { it("option description is included only when non-empty string", () => { const structuredRequest = { requestId: "req-desc", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { @@ -703,7 +703,7 @@ describe("derivePendingInputRequests", () => { it("option recommended is included only when true", () => { const structuredRequest = { requestId: "req-rec", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { @@ -737,7 +737,7 @@ describe("derivePendingInputRequests", () => { it("preserves option previews and preview formats", () => { const structuredRequest = { requestId: "req-preview", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { @@ -775,7 +775,7 @@ describe("derivePendingInputRequests", () => { it("parses question with all optional fields", () => { const structuredRequest = { requestId: "req-q", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { @@ -818,7 +818,7 @@ describe("derivePendingInputRequests", () => { it("omits optional question fields when not provided or empty", () => { const structuredRequest = { requestId: "req-q-min", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { @@ -855,7 +855,7 @@ describe("derivePendingInputRequests", () => { it("filters out questions with missing id or question text", () => { const structuredRequest = { requestId: "req-filter", - source: "mission", + source: "ade", kind: "structured_question", questions: [ { id: "", question: "no id" }, @@ -929,7 +929,7 @@ describe("derivePendingInputRequests", () => { it("blocking defaults to true when not explicitly false", () => { const structuredRequest = { requestId: "req-block", - source: "mission", + source: "ade", kind: "approval", questions: [], }; @@ -1012,7 +1012,7 @@ describe("derivePendingInputRequests", () => { it("providerMetadata is omitted when not a valid record", () => { const structuredRequest = { requestId: "req-pm", - source: "mission", + source: "ade", kind: "approval", questions: [], providerMetadata: "not-an-object", diff --git a/apps/desktop/src/renderer/components/chat/pendingInput.ts b/apps/desktop/src/renderer/components/chat/pendingInput.ts index 215157f5f..cef5da768 100644 --- a/apps/desktop/src/renderer/components/chat/pendingInput.ts +++ b/apps/desktop/src/renderer/components/chat/pendingInput.ts @@ -121,7 +121,7 @@ export function buildLegacyPendingInputFromApprovalEvent( return { requestId: event.itemId, itemId: event.itemId, - source: "mission", + source: "ade", kind: optionList.length ? "structured_question" : "question", description: question, questions: [ @@ -144,7 +144,7 @@ export function buildLegacyPendingInputFromApprovalEvent( return { requestId: event.itemId, itemId: event.itemId, - source: "mission", + source: "ade", kind: "approval", description: event.description, questions: [], @@ -162,7 +162,7 @@ function buildLegacyPendingInputFromStructuredQuestion(envelope: AgentChatEventE return { requestId: event.itemId, itemId: event.itemId, - source: "mission", + source: "ade", kind: "structured_question", description: event.question, questions: [ diff --git a/apps/desktop/src/renderer/components/chat/toolPresentation.ts b/apps/desktop/src/renderer/components/chat/toolPresentation.ts index 457cc3c82..0ce8fced8 100644 --- a/apps/desktop/src/renderer/components/chat/toolPresentation.ts +++ b/apps/desktop/src/renderer/components/chat/toolPresentation.ts @@ -40,7 +40,6 @@ const TOOL_LABEL_OVERRIDES: Record<string, string> = { request_specialist: "Request specialist", delegate_to_subagent: "Delegate", delegate_parallel: "Delegate batch", - read_mission_status: "Mission status", get_worker_output: "Worker output", revise_plan: "Revise plan", retry_step: "Retry step", diff --git a/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx b/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx index 206296dd7..2c5ecdee3 100644 --- a/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx +++ b/apps/desktop/src/renderer/components/cto/ctoUi.test.tsx @@ -320,7 +320,6 @@ describe("LinearSyncPanel (file group)", () => { currentStepIndex: 0, currentStepId: null, executionLaneId: null, - linkedMissionId: null, linkedSessionId: null, linkedWorkerRunId: null, linkedPrId: null, diff --git a/apps/desktop/src/renderer/components/cto/pipeline/PipelineCanvas.tsx b/apps/desktop/src/renderer/components/cto/pipeline/PipelineCanvas.tsx index 365e8b4c1..6aa0919fd 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/PipelineCanvas.tsx +++ b/apps/desktop/src/renderer/components/cto/pipeline/PipelineCanvas.tsx @@ -155,7 +155,7 @@ export function PipelineCanvas({ workflow, onUpdateWorkflow, onSave, saving, age {showAddPopover !== null && ( <div className="flex items-center justify-center gap-2 py-2 px-4 border-t border-white/[0.04]"> <span className="text-[10px] text-muted-fg/35 mr-2">Add stage:</span> - {(["worker_run", "employee_session", "mission", "pr_resolution", "review_gate"] as const).map((type) => ( + {(["worker_run", "employee_session", "pr_resolution", "review_gate"] as const).map((type) => ( <button key={type} type="button" diff --git a/apps/desktop/src/renderer/components/cto/pipeline/StageCard.tsx b/apps/desktop/src/renderer/components/cto/pipeline/StageCard.tsx index a90fc1c87..249eb3dcd 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/StageCard.tsx +++ b/apps/desktop/src/renderer/components/cto/pipeline/StageCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Robot, Lightning as LightningIcon, GitBranch, ShieldCheck, Kanban } from "@phosphor-icons/react"; +import { Robot, Lightning as LightningIcon, GitBranch, ShieldCheck } from "@phosphor-icons/react"; import type { LinearWorkflowTarget } from "../../../../shared/types/linearSync"; import { TARGET_TYPE_LABELS, @@ -15,7 +15,6 @@ import { cn } from "../../ui/cn"; const TYPE_ICONS: Record<string, React.ElementType> = { employee_session: Robot, worker_run: LightningIcon, - mission: Kanban, pr_resolution: GitBranch, review_gate: ShieldCheck, }; diff --git a/apps/desktop/src/renderer/components/cto/pipeline/WorkflowListSidebar.tsx b/apps/desktop/src/renderer/components/cto/pipeline/WorkflowListSidebar.tsx index 0704c2e97..862a4993b 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/WorkflowListSidebar.tsx +++ b/apps/desktop/src/renderer/components/cto/pipeline/WorkflowListSidebar.tsx @@ -8,7 +8,6 @@ import { TARGET_TYPE_LABELS, STAGE_COLORS, PRESET_TEMPLATE_DESCRIPTIONS } from " const PRESET_TEMPLATES: ReadonlyArray<{ type: LinearWorkflowTargetType; label: string; color: string; description: string }> = [ { type: "employee_session", label: "Employee", color: STAGE_COLORS.employee_session, description: PRESET_TEMPLATE_DESCRIPTIONS.employee_session }, - { type: "mission", label: "Mission", color: STAGE_COLORS.mission, description: PRESET_TEMPLATE_DESCRIPTIONS.mission }, { type: "worker_run", label: "Worker Run", color: STAGE_COLORS.worker_run, description: PRESET_TEMPLATE_DESCRIPTIONS.worker_run }, { type: "pr_resolution", label: "PR Resolution", color: STAGE_COLORS.pr_resolution, description: PRESET_TEMPLATE_DESCRIPTIONS.pr_resolution }, { type: "review_gate", label: "Review Gate", color: STAGE_COLORS.review_gate, description: PRESET_TEMPLATE_DESCRIPTIONS.review_gate }, diff --git a/apps/desktop/src/renderer/components/cto/pipeline/config/ExecutionConfig.tsx b/apps/desktop/src/renderer/components/cto/pipeline/config/ExecutionConfig.tsx index 527537efa..ff7ec4efe 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/config/ExecutionConfig.tsx +++ b/apps/desktop/src/renderer/components/cto/pipeline/config/ExecutionConfig.tsx @@ -34,9 +34,9 @@ function toOptions(labels: Record<string, { displayName: string; description?: s export function ExecutionConfig({ stage, agents, onUpdate }: Props) { const accent = STAGE_COLORS[stage.type] ?? "#A78BFA"; const showSessionHandling = stage.type === "employee_session"; - const showWorkerSelector = stage.type === "worker_run" || stage.type === "mission" || stage.type === "pr_resolution"; + const showWorkerSelector = stage.type === "worker_run" || stage.type === "pr_resolution"; const showPrStrategy = stage.type !== "review_gate"; - const showLane = stage.type !== "mission" && stage.type !== "review_gate"; + const showLane = stage.type !== "review_gate"; return ( <div className="space-y-5"> diff --git a/apps/desktop/src/renderer/components/cto/pipeline/pipeline.test.ts b/apps/desktop/src/renderer/components/cto/pipeline/pipeline.test.ts index 9f8eea07c..9673457c1 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/pipeline.test.ts +++ b/apps/desktop/src/renderer/components/cto/pipeline/pipeline.test.ts @@ -58,7 +58,6 @@ describe("pipelineHelpers (file group)", () => { const types: LinearWorkflowTarget["type"][] = [ "worker_run", "employee_session", - "mission", "pr_resolution", "review_gate", ]; @@ -111,9 +110,8 @@ describe("pipelineHelpers (file group)", () => { it("strips downstreamTarget from each stage", () => { const target = makeTarget({ downstreamTarget: { - type: "mission", + type: "pr_resolution", runMode: "autopilot", - missionTemplate: "default", }, }); const stages = flattenTargetChain(target); @@ -209,12 +207,12 @@ describe("pipelineHelpers (file group)", () => { it("appends at the end", () => { const target = makeTarget(); - const newStage: PipelineStage = { type: "mission", runMode: "autopilot" }; + const newStage: PipelineStage = { type: "review_gate", runMode: "manual" }; const result = insertStageAt(target, 1, newStage); const flat = flattenTargetChain(result); expect(flat).toHaveLength(2); expect(flat[0].type).toBe("worker_run"); - expect(flat[1].type).toBe("mission"); + expect(flat[1].type).toBe("review_gate"); }); it("inserts in the middle of a 3-stage chain", () => { @@ -228,7 +226,7 @@ describe("pipelineHelpers (file group)", () => { it("throws for out-of-bounds index", () => { const target = makeTarget(); - const newStage: PipelineStage = { type: "mission", runMode: "autopilot" }; + const newStage: PipelineStage = { type: "review_gate", runMode: "manual" }; expect(() => insertStageAt(target, -1, newStage)).toThrow("out of range"); expect(() => insertStageAt(target, 5, newStage)).toThrow("out of range"); }); @@ -278,7 +276,7 @@ describe("pipelineHelpers (file group)", () => { expect(flat[1].runMode).toBe("manual"); // Other stages unchanged expect(flat[0].type).toBe("worker_run"); - expect(flat[2].type).toBe("mission"); + expect(flat[2].type).toBe("pr_resolution"); }); it("throws for out-of-bounds index", () => { @@ -319,13 +317,6 @@ describe("pipelineHelpers (file group)", () => { expect(stage.prTiming).toBe("none"); }); - it("creates a default mission stage", () => { - const stage = createDefaultStage("mission"); - expect(stage.type).toBe("mission"); - expect(stage.runMode).toBe("autopilot"); - expect(stage.missionTemplate).toBe("default"); - }); - it("creates a default pr_resolution stage", () => { const stage = createDefaultStage("pr_resolution"); expect(stage.type).toBe("pr_resolution"); @@ -379,7 +370,7 @@ describe("pipelineLabels (file group)", () => { describe("label constants", () => { it("TARGET_TYPE_LABELS covers all target types", () => { - const expectedTypes = ["employee_session", "worker_run", "mission", "pr_resolution", "review_gate"]; + const expectedTypes = ["employee_session", "worker_run", "pr_resolution", "review_gate"]; for (const type of expectedTypes) { expect(TARGET_TYPE_LABELS[type], `missing label for target type: ${type}`).toBeDefined(); expect(TARGET_TYPE_LABELS[type].displayName).toBeTruthy(); @@ -421,7 +412,7 @@ describe("pipelineLabels (file group)", () => { }); it("STAGE_COLORS maps all target types to hex colors", () => { - const types = ["employee_session", "worker_run", "mission", "pr_resolution", "review_gate"]; + const types = ["employee_session", "worker_run", "pr_resolution", "review_gate"]; for (const type of types) { expect(STAGE_COLORS[type], `missing color for: ${type}`).toBeTruthy(); expect(STAGE_COLORS[type]).toMatch(/^#[0-9A-Fa-f]{6}$/); @@ -429,7 +420,7 @@ describe("pipelineLabels (file group)", () => { }); it("PRESET_TEMPLATE_DESCRIPTIONS covers all target types", () => { - const types = ["employee_session", "mission", "worker_run", "pr_resolution", "review_gate"]; + const types = ["employee_session", "worker_run", "pr_resolution", "review_gate"]; for (const type of types) { expect(PRESET_TEMPLATE_DESCRIPTIONS[type], `missing description for: ${type}`).toBeTruthy(); } diff --git a/apps/desktop/src/renderer/components/cto/pipeline/pipelineHelpers.ts b/apps/desktop/src/renderer/components/cto/pipeline/pipelineHelpers.ts index 5de82e137..12efc0c14 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/pipelineHelpers.ts +++ b/apps/desktop/src/renderer/components/cto/pipeline/pipelineHelpers.ts @@ -142,12 +142,6 @@ export function createDefaultStage( laneSelection: "fresh_issue_lane", prTiming: "none", }; - case "mission": - return { - type, - runMode: "autopilot", - missionTemplate: "default", - }; case "pr_resolution": return { type, @@ -162,5 +156,13 @@ export function createDefaultStage( type, runMode: "manual", }; + default: + return { + type: "worker_run", + runMode: "autopilot", + workerSelector: { mode: "none" }, + laneSelection: "fresh_issue_lane", + prTiming: "none", + }; } } diff --git a/apps/desktop/src/renderer/components/cto/pipeline/pipelineLabels.ts b/apps/desktop/src/renderer/components/cto/pipeline/pipelineLabels.ts index b88d0686a..6b55adf46 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/pipelineLabels.ts +++ b/apps/desktop/src/renderer/components/cto/pipeline/pipelineLabels.ts @@ -54,8 +54,6 @@ export const FIELD_LABELS: Record<string, FieldLabel> = { "target.prStrategy.draft": { displayName: "Start as Draft PR", description: "Create the PR in draft mode until it's ready for review.", tier: "advanced" }, "target.prTiming": { displayName: "PR Timing", description: "When in the workflow ADE creates or links the PR.", tier: "essential" }, "target.sessionTemplate": { displayName: "Session Template", description: "Template to use when creating the agent session.", tier: "expert" }, - "target.missionTemplate": { displayName: "Mission Template", description: "Template to use when creating the mission.", tier: "expert" }, - "target.phaseProfile": { displayName: "Phase Profile", description: "Named phase configuration for multi-phase missions.", tier: "expert" }, "target.downstreamTarget": { displayName: "Follow-up Action", description: "A second action that runs after the primary work completes.", tier: "expert" }, // Visual plan @@ -109,7 +107,6 @@ export type EnumLabel = { export const TARGET_TYPE_LABELS: Record<string, EnumLabel> = { employee_session: { displayName: "Agent Chat Session", description: "Hand the issue to an agent in a chat session." }, worker_run: { displayName: "Background Worker", description: "Delegate to an isolated worker for autonomous work." }, - mission: { displayName: "Multi-Step Mission", description: "Broader mission with planning, execution, and verification." }, pr_resolution: { displayName: "PR Workflow", description: "Focus on creating and resolving a pull request." }, review_gate: { displayName: "Approval Gate", description: "Pause for human approval before continuing." }, }; @@ -147,7 +144,7 @@ export const PR_STRATEGY_KIND_LABELS: Record<string, EnumLabel> = { export const COMPLETION_CONTRACT_LABELS: Record<string, EnumLabel> = { complete_on_launch: { displayName: "Immediately after starting", description: "Done as soon as work is launched." }, wait_for_explicit_completion: { displayName: "Agent marks done", description: "Wait for the agent to explicitly report completion." }, - wait_for_runtime_success: { displayName: "Runtime finishes successfully", description: "Wait for the mission or worker to finish with success." }, + wait_for_runtime_success: { displayName: "Runtime finishes successfully", description: "Wait for the worker to finish with success." }, wait_for_pr_created: { displayName: "A pull request exists", description: "Wait until a linked PR has been created." }, wait_for_review_ready: { displayName: "PR passes review checks", description: "Wait until the PR is approved and checks pass." }, }; @@ -226,7 +223,6 @@ export const ISSUE_STATE_LABELS: Record<string, EnumLabel> = { export const STAGE_COLORS: Record<string, string> = { employee_session: "#A78BFA", // violet worker_run: "#34D399", // emerald - mission: "#A78BFA", // violet pr_resolution: "#FB7185", // rose review_gate: "#FBBF24", // amber }; @@ -235,7 +231,6 @@ export const STAGE_COLORS: Record<string, string> = { export const PRESET_TEMPLATE_DESCRIPTIONS: Record<string, string> = { employee_session: "Hand an issue to an agent in a live chat session.", - mission: "Broad multi-step mission with planning and verification.", worker_run: "Run isolated background work with a dedicated worker.", pr_resolution: "Create, track, and land a pull request automatically.", review_gate: "Pause the pipeline for human approval before continuing.", diff --git a/apps/desktop/src/renderer/components/history/EventDetailPanel.tsx b/apps/desktop/src/renderer/components/history/EventDetailPanel.tsx index b31b27e71..a57240b2b 100644 --- a/apps/desktop/src/renderer/components/history/EventDetailPanel.tsx +++ b/apps/desktop/src/renderer/components/history/EventDetailPanel.tsx @@ -30,7 +30,7 @@ function copyToClipboard(text: string) { function metadataId( metadata: Record<string, unknown> | null, - key: "missionId" | "sessionId", + key: "sessionId", ): string | null { if (!metadata) return null; const value = metadata[key]; @@ -137,7 +137,6 @@ function PanelContent({ const meta = getEventMeta(event.kind); const catMeta = CATEGORY_META[event.category]; const statusClasses = getStatusClasses(event.status); - const missionId = metadataId(event.metadata, "missionId"); const sessionId = metadataId(event.metadata, "sessionId"); return ( @@ -255,24 +254,6 @@ function PanelContent({ )} </MetaCell> - {missionId ? ( - <MetaCell label="Mission ID"> - <button - type="button" - onClick={() => copyToClipboard(missionId)} - className="group inline-flex max-w-full items-center gap-1 text-left" - title={`Copy ${missionId}`} - > - <span className="truncate font-mono text-[11px] text-accent">{missionId}</span> - <Copy - size={10} - weight="bold" - className="shrink-0 text-muted-fg opacity-0 transition-opacity group-hover:opacity-100" - /> - </button> - </MetaCell> - ) : null} - {sessionId ? ( <MetaCell label="Session ID"> <button @@ -402,14 +383,6 @@ function PanelContent({ </span> </Button> ) : null} - {event.category === "mission" && navigate ? ( - <Button variant="outline" size="sm" onClick={() => navigate("/missions")}> - <span className="inline-flex items-center gap-1"> - <ArrowSquareOut size={10} weight="bold" /> - Open missions - </span> - </Button> - ) : null} {event.category === "session" && event.laneId && navigate ? ( <Button variant="outline" diff --git a/apps/desktop/src/renderer/components/history/HistoryPage.tsx b/apps/desktop/src/renderer/components/history/HistoryPage.tsx index 941159fc5..8a4cc3f5e 100644 --- a/apps/desktop/src/renderer/components/history/HistoryPage.tsx +++ b/apps/desktop/src/renderer/components/history/HistoryPage.tsx @@ -184,7 +184,7 @@ function HistoryPageContent({ active = true }: { active?: boolean } = {}) { useEffect(() => { if (!active || surface !== "activity") return; // Tight polling only refreshes the cheap operations feed; supplemental sources - // (missions, CTO, worker runs) refresh on focus/visibility change instead. + // Supplemental sources refresh on focus/visibility change instead. const tightRefresh = () => { if (document.visibilityState !== "visible") return; void fetchEvents({ silent: true, skipSupplemental: true }); diff --git a/apps/desktop/src/renderer/components/history/eventTaxonomy.ts b/apps/desktop/src/renderer/components/history/eventTaxonomy.ts index 1e0ce2aa9..e395ed49d 100644 --- a/apps/desktop/src/renderer/components/history/eventTaxonomy.ts +++ b/apps/desktop/src/renderer/components/history/eventTaxonomy.ts @@ -6,7 +6,6 @@ export type EventCategory = | "pack" | "conflict" | "pr" - | "mission" | "automation" | "process" | "system"; @@ -62,12 +61,6 @@ export const CATEGORY_META: Record<EventCategory, CategoryMeta> = { colorMuted: "rgba(6,182,212,0.15)", shape: "pill", }, - mission: { - label: "Missions", - color: "#8B5CF6", - colorMuted: "rgba(139,92,246,0.15)", - shape: "star", - }, automation: { label: "Automations", color: "#14B8A6", @@ -189,16 +182,6 @@ export const EVENT_KIND_META: Record<string, EventKindMeta> = { "pr.queue_entered": { label: "Merge Queue", category: "pr", iconName: "Queue", description: "Entered merge queue", importance: "high" }, "pr.integration_started": { label: "Integration", category: "pr", iconName: "Rocket", description: "Integration flow started", importance: "high" }, - // ── Mission & Orchestrator ────────────────────────────────── - "mission.started": { label: "Mission Started", category: "mission", iconName: "Rocket", description: "Mission launched", importance: "high" }, - "mission.update": { label: "Mission Update", category: "mission", iconName: "ListChecks", description: "Mission state changed", importance: "medium" }, - "mission.completed": { label: "Mission Done", category: "mission", iconName: "Trophy", description: "Mission completed", importance: "high" }, - "mission.failed": { label: "Mission Failed", category: "mission", iconName: "XCircle", description: "Mission failed", importance: "high" }, - "mission.step_progress": { label: "Step Progress", category: "mission", iconName: "Spinner", description: "Step in progress", importance: "low" }, - "mission.intervention": { label: "Intervention", category: "mission", iconName: "HandPalm", description: "Intervention required", importance: "high" }, - "mission.worker_status": { label: "Worker Update", category: "mission", iconName: "Robot", description: "Worker status update", importance: "low" }, - "mission.plan_revised": { label: "Plan Revised", category: "mission", iconName: "NotePencil", description: "Execution plan revised", importance: "medium" }, - // ── Automation ────────────────────────────────────────────── "automation.triggered": { label: "Triggered", category: "automation", iconName: "Lightning", description: "Automation triggered", importance: "high" }, "automation.completed": { label: "Completed", category: "automation", iconName: "CheckCircle", description: "Automation completed", importance: "high" }, @@ -222,15 +205,15 @@ export const EVENT_KIND_META: Record<string, EventKindMeta> = { // ── Unified ADE work feed ───────────────────────────────── "chat.session": { label: "Chat Session", category: "session", iconName: "ChatCircleText", description: "ADE chat session activity", importance: "medium" }, "cto.session": { label: "CTO Session", category: "session", iconName: "Strategy", description: "CTO coordinator session update", importance: "medium" }, - "worker.run": { label: "Worker Run", category: "mission", iconName: "Robot", description: "Worker task run", importance: "medium" }, - "worker.activity": { label: "Worker Activity", category: "mission", iconName: "Users", description: "Worker activity update", importance: "low" }, + "worker.run": { label: "Worker Run", category: "automation", iconName: "Robot", description: "Worker task run", importance: "medium" }, + "worker.activity": { label: "Worker Activity", category: "automation", iconName: "Users", description: "Worker activity update", importance: "low" }, // ── Noise-level events (hidden by default) ───────────────── "tool_call": { label: "Tool Call", category: "system", iconName: "Wrench", description: "Tool invocation", importance: "noise" }, - "worker": { label: "Worker", category: "mission", iconName: "Robot", description: "Worker orchestration step", importance: "noise" }, - "implementation": { label: "Implementation", category: "mission", iconName: "Code", description: "Implementation step", importance: "noise" }, - "coordinator": { label: "Coordinator", category: "mission", iconName: "TreeStructure", description: "Coordinator operation", importance: "noise" }, - "opencode": { label: "OpenCode", category: "mission", iconName: "Stack", description: "OpenCode orchestrator step", importance: "noise" }, + "worker": { label: "Worker", category: "automation", iconName: "Robot", description: "Worker step", importance: "noise" }, + "implementation": { label: "Implementation", category: "automation", iconName: "Code", description: "Implementation step", importance: "noise" }, + "coordinator": { label: "Coordinator", category: "automation", iconName: "TreeStructure", description: "Coordinator operation", importance: "noise" }, + "opencode": { label: "OpenCode", category: "automation", iconName: "Stack", description: "OpenCode step", importance: "noise" }, "manual": { label: "Manual Op", category: "system", iconName: "Hand", description: "Manual operation", importance: "noise" }, "heading": { label: "Heading", category: "system", iconName: "TextH", description: "Section heading marker", importance: "noise" }, "markers": { label: "Markers", category: "system", iconName: "MapPin", description: "Context markers update", importance: "noise" }, @@ -238,7 +221,7 @@ export const EVENT_KIND_META: Record<string, EventKindMeta> = { "validation": { label: "Validation", category: "system", iconName: "CheckSquare", description: "Validation check", importance: "noise" }, "queue": { label: "Queue", category: "system", iconName: "Queue", description: "Queue processing", importance: "noise" }, "task": { label: "Task", category: "system", iconName: "ListChecks", description: "Background task", importance: "noise" }, - "teammate": { label: "Teammate", category: "mission", iconName: "Users", description: "Teammate coordination", importance: "noise" }, + "teammate": { label: "Teammate", category: "automation", iconName: "Users", description: "Teammate coordination", importance: "noise" }, "integration": { label: "Integration Step", category: "pr", iconName: "GitMerge", description: "Integration substep", importance: "noise" }, }; diff --git a/apps/desktop/src/renderer/components/history/historyActivitySources.test.ts b/apps/desktop/src/renderer/components/history/historyActivitySources.test.ts index 89ab1c893..8d2648a53 100644 --- a/apps/desktop/src/renderer/components/history/historyActivitySources.test.ts +++ b/apps/desktop/src/renderer/components/history/historyActivitySources.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatSessionSummary, CtoSnapshot, - MissionSummary, WorkerAgentRun, } from "../../../shared/types"; import { getEventMeta } from "./eventTaxonomy"; @@ -26,28 +25,6 @@ const chat: AgentChatSessionSummary = { summary: "Investigating the checkout failure", }; -const mission: MissionSummary = { - id: "mission-1", - title: "Ship history tab", - prompt: "Finish the History tab", - laneId: "lane-1", - laneName: "History lane", - status: "completed", - priority: "high", - executionMode: "local", - targetMachineId: null, - outcomeSummary: "History shipped", - lastError: null, - artifactCount: 2, - openInterventions: 0, - totalSteps: 4, - completedSteps: 4, - createdAt: "2026-05-22T00:00:00.000Z", - updatedAt: "2026-05-22T00:20:00.000Z", - startedAt: "2026-05-22T00:01:00.000Z", - completedAt: "2026-05-22T00:21:00.000Z", -}; - const workerRun: WorkerAgentRun = { id: "run-1", agentId: "agent-1", @@ -103,23 +80,20 @@ describe("history supplemental activity sources", () => { vi.unstubAllGlobals(); }); - it("maps chats, missions, CTO logs, and worker task runs into timeline records", () => { + it("maps chats, CTO logs, and worker task runs into timeline records", () => { const records = buildSupplementalTimelineRecords({ chats: [chat], - missions: [mission], workerRuns: [workerRun], ctoSnapshot, }); expect(records.map((record) => record.kind)).toEqual(expect.arrayContaining([ "chat.session", - "mission.completed", "worker.run", "cto.session", "worker.activity", ])); expect(records.find((record) => record.id === "chat:chat-1")?.status).toBe("running"); - expect(records.find((record) => record.id === "mission:mission-1")?.status).toBe("succeeded"); expect(records.find((record) => record.id === "worker-run:run-1")?.status).toBe("failed"); }); @@ -134,15 +108,13 @@ describe("history supplemental activity sources", () => { expect(event.metadata?.sessionId).toBe("chat-1"); }); - it("fetches chats, missions, CTO state, and worker runs through renderer APIs", async () => { + it("fetches chats, CTO state, and worker runs through renderer APIs", async () => { const agentChatList = vi.fn(async () => [chat]); - const missionsList = vi.fn(async () => [mission]); const ctoGetState = vi.fn(async () => ctoSnapshot); const listAgentRuns = vi.fn(async () => [workerRun]); vi.stubGlobal("window", { ade: { agentChat: { list: agentChatList }, - missions: { list: missionsList }, cto: { getState: ctoGetState, listAgentRuns, @@ -153,12 +125,10 @@ describe("history supplemental activity sources", () => { const records = await fetchSupplementalTimelineRecords(1000); expect(agentChatList).toHaveBeenCalledWith({ includeAutomation: true }); - expect(missionsList).toHaveBeenCalledWith({ limit: 500, includeArchived: true }); expect(ctoGetState).toHaveBeenCalledWith({ recentLimit: 100 }); expect(listAgentRuns).toHaveBeenCalledWith({ limit: 100 }); expect(records.map((record) => record.kind)).toEqual(expect.arrayContaining([ "chat.session", - "mission.completed", "worker.run", "cto.session", "worker.activity", diff --git a/apps/desktop/src/renderer/components/history/historyActivitySources.ts b/apps/desktop/src/renderer/components/history/historyActivitySources.ts index cb714c04e..6b7506a19 100644 --- a/apps/desktop/src/renderer/components/history/historyActivitySources.ts +++ b/apps/desktop/src/renderer/components/history/historyActivitySources.ts @@ -1,7 +1,6 @@ import type { AgentChatSessionSummary, CtoSnapshot, - MissionSummary, OperationRecord, WorkerAgentRun, } from "../../../shared/types"; @@ -10,7 +9,6 @@ type OperationStatus = OperationRecord["status"]; export type HistoryActivitySourceData = { chats?: AgentChatSessionSummary[]; - missions?: MissionSummary[]; ctoSnapshot?: CtoSnapshot | null; workerRuns?: WorkerAgentRun[]; }; @@ -39,19 +37,6 @@ function chatStatus(status: AgentChatSessionSummary["status"]): OperationStatus return status === "active" ? "running" : "succeeded"; } -function missionStatus(status: MissionSummary["status"]): OperationStatus { - switch (status) { - case "completed": - return "succeeded"; - case "failed": - return "failed"; - case "canceled": - return "canceled"; - default: - return "running"; - } -} - function workerStatus(status: WorkerAgentRun["status"]): OperationStatus { switch (status) { case "completed": @@ -66,19 +51,6 @@ function workerStatus(status: WorkerAgentRun["status"]): OperationStatus { } } -function missionKind(status: MissionSummary["status"]): string { - switch (status) { - case "completed": - return "mission.completed"; - case "failed": - return "mission.failed"; - case "intervention_required": - return "mission.intervention"; - default: - return "mission.update"; - } -} - function laneFromContext(context: Record<string, unknown>): { laneId: string | null; laneName: string | null; @@ -130,43 +102,6 @@ function chatRecord(chat: AgentChatSessionSummary): OperationRecord | null { }; } -function missionRecord(mission: MissionSummary): OperationRecord | null { - const timestamp = - validIso(mission.completedAt) ?? - validIso(mission.updatedAt) ?? - validIso(mission.startedAt) ?? - validIso(mission.createdAt); - if (!timestamp) return null; - const status = missionStatus(mission.status); - - return { - id: `mission:${mission.id}`, - laneId: mission.laneId, - laneName: mission.laneName, - kind: missionKind(mission.status), - startedAt: timestamp, - endedAt: terminalEndedAt(status, timestamp), - status, - preHeadSha: null, - postHeadSha: null, - metadataJson: metadataJson({ - source: "mission", - eventLabel: `Mission: ${mission.title}`, - missionId: mission.id, - title: mission.title, - summary: mission.outcomeSummary ?? mission.prompt, - actor: "mission", - priority: mission.priority, - missionStatus: mission.status, - completedSteps: mission.completedSteps, - totalSteps: mission.totalSteps, - openInterventions: mission.openInterventions, - artifactCount: mission.artifactCount, - lastError: mission.lastError, - }), - }; -} - function workerRunRecord(run: WorkerAgentRun): OperationRecord | null { const timestamp = validIso(run.finishedAt) ?? @@ -279,11 +214,6 @@ export function buildSupplementalTimelineRecords( if (record) records.push(record); } - for (const mission of data.missions ?? []) { - const record = missionRecord(mission); - if (record) records.push(record); - } - for (const run of data.workerRuns ?? []) { const record = workerRunRecord(run); if (record) records.push(record); @@ -312,13 +242,10 @@ export async function fetchSupplementalTimelineRecords( const safeLimit = Number.isFinite(roundedLimit) ? Math.max(1, Math.min(500, roundedLimit)) : 500; - const [chats, missions, ctoSnapshot, workerRuns] = await Promise.all([ + const [chats, ctoSnapshot, workerRuns] = await Promise.all([ typeof window.ade?.agentChat?.list === "function" ? settle(window.ade.agentChat.list({ includeAutomation: true })) : Promise.resolve(null), - typeof window.ade?.missions?.list === "function" - ? settle(window.ade.missions.list({ limit: safeLimit, includeArchived: true })) - : Promise.resolve(null), typeof window.ade?.cto?.getState === "function" ? settle(window.ade.cto.getState({ recentLimit: Math.min(100, safeLimit) })) : Promise.resolve(null), @@ -329,7 +256,6 @@ export async function fetchSupplementalTimelineRecords( return buildSupplementalTimelineRecords({ chats: chats ?? [], - missions: missions ?? [], ctoSnapshot, workerRuns: workerRuns ?? [], }); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 15ddd288d..00e3a0c70 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -46,8 +46,6 @@ import { sortLanesForStackGraph, mergeUnique, laneMatchesFilter, - isMissionLaneHiddenByDefault, - isMissionResultLane, LANES_TILING_TREE, LANES_TILING_WORK_FOCUS_TREE, LANES_TILING_LAYOUT_VERSION, @@ -770,8 +768,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const branchLaneSwitchDisabledReason = useMemo<string | null>(() => { if (!branchLane) return null; if (branchLane.laneType === "attached") return "Branch switching is disabled for attached lanes — manage this worktree with your own tools."; - if (isMissionResultLane(branchLane)) return "Branch switching is disabled for mission result lanes to keep their output stable."; - if (isMissionLaneHiddenByDefault(branchLane)) return "Branch switching isn't available on mission worker lanes."; return null; }, [branchLane]); const canSwitchBranchLane = branchLane !== null && branchLaneSwitchDisabledReason === null; diff --git a/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts b/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts index 8f31c07ee..71b47b3db 100644 --- a/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts @@ -5,7 +5,6 @@ import { LANES_TILING_TREE, LANES_TILING_WORK_FOCUS_TREE, formatBranchCheckoutError, - isMissionLaneHiddenByDefault, laneMatchesFilter, stripRemotePrefix, validateBranchName, @@ -39,8 +38,6 @@ function makeLane(overrides: Partial<LaneSummary> = {}): LaneSummary { icon: null, tags: [], folder: null, - missionId: null, - laneRole: null, createdAt: "2026-03-30T10:00:00.000Z", archivedAt: null, ...overrides, @@ -68,17 +65,6 @@ describe("laneUtils tiling defaults", () => { ); }); - it("hides non-result mission lanes by default but reveals them with mission filters", () => { - const workerLane = makeLane({ missionId: "mission-1", laneRole: "worker", name: "Mission worker" }); - const resultLane = makeLane({ missionId: "mission-1", laneRole: "result", name: "Mission result" }); - - expect(isMissionLaneHiddenByDefault(workerLane)).toBe(true); - expect(laneMatchesFilter(workerLane, false, "")).toBe(false); - expect(laneMatchesFilter(workerLane, false, "is:mission")).toBe(true); - expect(laneMatchesFilter(resultLane, false, "")).toBe(true); - expect(laneMatchesFilter(resultLane, false, "is:mission-result")).toBe(true); - }); - describe("validateBranchName", () => { it("accepts ordinary names", () => { expect(validateBranchName("feature/foo").ok).toBe(true); diff --git a/apps/desktop/src/renderer/components/lanes/laneUtils.ts b/apps/desktop/src/renderer/components/lanes/laneUtils.ts index cbe6c4494..d8e542e65 100644 --- a/apps/desktop/src/renderer/components/lanes/laneUtils.ts +++ b/apps/desktop/src/renderer/components/lanes/laneUtils.ts @@ -74,14 +74,6 @@ export function mergeUnique(...lists: string[][]): string[] { /* ---- Filter helpers ---- */ -export function isMissionResultLane(lane: Pick<LaneSummary, "missionId" | "laneRole">): boolean { - return Boolean(lane.missionId) && lane.laneRole === "result"; -} - -export function isMissionLaneHiddenByDefault(lane: Pick<LaneSummary, "missionId" | "laneRole">): boolean { - return Boolean(lane.missionId) && !isMissionResultLane(lane); -} - export function matchesLaneFilterToken(lane: LaneSummary, isPinned: boolean, token: string): boolean { const normalized = token.trim().toLowerCase(); if (!normalized.length) return true; @@ -93,15 +85,13 @@ export function matchesLaneFilterToken(lane: LaneSummary, isPinned: boolean, tok if (value === "primary") return lane.laneType === "primary"; if (value === "worktree") return lane.laneType === "worktree"; if (value === "attached") return lane.laneType === "attached"; - if (value === "mission") return Boolean(lane.missionId); - if (value === "mission-result") return isMissionResultLane(lane); return false; } if (normalized.startsWith("type:")) return lane.laneType === normalized.slice(5); const indexedText = [ lane.name, lane.branchRef, lane.laneType, lane.description ?? "", - lane.worktreePath, lane.folder ?? "", lane.tags.join(" "), lane.laneRole ?? "", + lane.worktreePath, lane.folder ?? "", lane.tags.join(" "), lane.status.dirty ? "dirty modified changed" : "clean", lane.status.ahead > 0 ? `ahead ahead:${lane.status.ahead}` : "ahead:0", lane.status.behind > 0 ? `behind behind:${lane.status.behind}` : "behind:0", @@ -112,8 +102,6 @@ export function matchesLaneFilterToken(lane: LaneSummary, isPinned: boolean, tok export function laneMatchesFilter(lane: LaneSummary, isPinned: boolean, query: string): boolean { const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - const includesMissionLanes = tokens.includes("is:mission") || tokens.includes("is:mission-result"); - if (isMissionLaneHiddenByDefault(lane) && !includesMissionLanes) return false; if (tokens.length === 0) return true; return tokens.every((token) => matchesLaneFilterToken(lane, isPinned, token)); } diff --git a/apps/desktop/src/renderer/components/missions/ActivityFeed.tsx b/apps/desktop/src/renderer/components/missions/ActivityFeed.tsx deleted file mode 100644 index 078cbd19c..000000000 --- a/apps/desktop/src/renderer/components/missions/ActivityFeed.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React, { useRef, useEffect, useState, useCallback } from "react"; -import { CaretDown } from "@phosphor-icons/react"; -import type { OrchestratorChatMessage } from "../../../shared/types"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; - -type ActivityFeedProps = { - messages: OrchestratorChatMessage[]; - agentColors: Map<string, string>; - selectedAgent: string | null; -}; - -export function ActivityFeed({ messages, agentColors, selectedAgent: _selectedAgent }: ActivityFeedProps) { - const scrollRef = useRef<HTMLDivElement>(null); - const [showJumpToLatest, setShowJumpToLatest] = useState(false); - const isAtBottomRef = useRef(true); - - // Auto-scroll when new messages arrive (if user is at bottom) - useEffect(() => { - if (scrollRef.current && isAtBottomRef.current) { - scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); - } - }, [messages.length]); - - const handleScroll = useCallback(() => { - if (!scrollRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - const atBottom = scrollHeight - scrollTop - clientHeight < 60; - isAtBottomRef.current = atBottom; - setShowJumpToLatest(!atBottom); - }, []); - - const jumpToLatest = useCallback(() => { - if (scrollRef.current) { - scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); - } - setShowJumpToLatest(false); - isAtBottomRef.current = true; - }, []); - - return ( - <div - ref={scrollRef} - onScroll={handleScroll} - className="flex-1 overflow-y-auto px-4 py-3 space-y-1 relative" - > - {messages.length === 0 && ( - <div className="flex items-center justify-center h-full text-xs" style={{ color: COLORS.textMuted }}> - Waiting for mission activity... - </div> - )} - {messages.map((msg) => ( - <FeedEntry key={msg.id} msg={msg} agentColors={agentColors} /> - ))} - - {showJumpToLatest && ( - <button - onClick={jumpToLatest} - className="sticky bottom-2 left-1/2 -translate-x-1/2 px-3 py-1 font-bold shadow-lg hover:opacity-90 transition-opacity flex items-center gap-1" - style={{ - background: COLORS.accent, - color: COLORS.pageBg, - fontFamily: MONO_FONT, - fontSize: "10px", - textTransform: "uppercase" as const, - letterSpacing: "1px" - }} - > - <CaretDown size={12} weight="regular" /> - LATEST - </button> - )} - </div> - ); -} - -const FeedEntry = React.memo(function FeedEntry({ msg, agentColors }: { msg: OrchestratorChatMessage; agentColors: Map<string, string> }) { - const isUser = msg.role === "user"; - const isCoordinator = msg.role === "orchestrator"; - - // Determine the step key label - const label = msg.stepKey ?? (isCoordinator ? "coordinator" : isUser ? "you" : "system"); - const color = agentColors.get(label) ?? (isCoordinator ? COLORS.accent : isUser ? COLORS.info : COLORS.textMuted); - - // System events -- short orchestrator messages - if (isCoordinator && msg.content.length < 80 && !msg.content.includes("\n")) { - return ( - <div className="flex items-start gap-2 py-0.5"> - <span - className="shrink-0 text-[10px] w-[100px] text-right" - style={{ color: COLORS.textDim, fontFamily: MONO_FONT }} - > - system - </span> - <span className="text-xs" style={{ color: COLORS.textMuted }}> - {msg.content} - </span> - </div> - ); - } - - // User messages - if (isUser) { - return ( - <div className="flex items-start gap-2 py-0.5"> - <span - className="shrink-0 text-[10px] w-[100px] text-right font-semibold" - style={{ color: COLORS.info, fontFamily: MONO_FONT }} - > - you - </span> - <span className="text-xs" style={{ color: COLORS.textPrimary }}> - {msg.content} - </span> - </div> - ); - } - - // Inter-agent messages - const isInterAgent = msg.role === "agent" && msg.target?.kind === "agent"; - if (isInterAgent) { - const srcKey = msg.stepKey ?? "agent"; - const dstKey = msg.target && "targetAttemptId" in msg.target - ? (msg.target as any).stepKey ?? "agent" - : "orchestrator"; - return ( - <div className="flex items-start gap-2 py-0.5"> - <span - className="shrink-0 text-[10px] w-[100px] text-right" - style={{ color: agentColors.get(srcKey) ?? COLORS.accent, fontFamily: MONO_FONT }} - > - {srcKey} → {dstKey} - </span> - <span className="text-xs" style={{ color: COLORS.textSecondary }}> - {msg.content} - </span> - </div> - ); - } - - // Coordinator messages -- slightly different style - if (isCoordinator) { - return ( - <div className="flex items-start gap-2 py-0.5"> - <span - className="shrink-0 text-[10px] w-[100px] text-right font-semibold" - style={{ color: COLORS.accent, fontFamily: MONO_FONT }} - > - coordinator - </span> - <span className="text-xs" style={{ color: "#E0D4FF" }}> - {msg.content} - </span> - </div> - ); - } - - // Standard agent/worker messages - return ( - <div className="flex items-start gap-2 py-0.5"> - <span - className="shrink-0 text-[10px] w-[100px] text-right" - title={new Date(msg.timestamp).toLocaleTimeString()} - style={{ color, fontFamily: MONO_FONT }} - > - {label} - </span> - <span - className="text-xs" - style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }} - > - {msg.content} - </span> - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/ActivityNarrativeHeader.tsx b/apps/desktop/src/renderer/components/missions/ActivityNarrativeHeader.tsx deleted file mode 100644 index 173a03e75..000000000 --- a/apps/desktop/src/renderer/components/missions/ActivityNarrativeHeader.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { - CheckCircle, - Lightning, - Robot, - ChatCircle, -} from "@phosphor-icons/react"; -import type { OrchestratorRunGraph } from "../../../shared/types"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; -import { relativeWhen } from "../../lib/format"; -import { - NOISY_EVENT_TYPES, - filterExecutionSteps, - narrativeForEvent, - narrativeSummary, - iconForEventType, - iconHexForEventType, - type SteeringEntry, -} from "./missionHelpers"; - -export function ActivityNarrativeHeader({ - runGraph, - steeringLog -}: { - runGraph: OrchestratorRunGraph | null; - steeringLog: SteeringEntry[]; -}) { - if (!runGraph) { - return ( - <div className="px-3 py-3 text-center" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-xs" style={{ color: COLORS.textMuted }}>No orchestrator run yet. Start a run to see activity.</div> - </div> - ); - } - - const steps = filterExecutionSteps(runGraph.steps); - const totalSteps = steps.length; - const succeededCount = steps.filter((s) => s.status === "succeeded").length; - const runningCount = steps.filter((s) => s.status === "running").length; - const pendingCount = steps.filter((s) => s.status === "pending" || s.status === "ready" || s.status === "blocked").length; - const failedCount = steps.filter((s) => s.status === "failed").length; - const skippedCount = steps.filter((s) => s.status === "skipped").length; - - const runningAttempts = runGraph.attempts.filter((a) => a.status === "running"); - const activeExecutorKinds = [...new Set(runningAttempts.map((a) => a.executorKind))]; - const activeAgentCount = runningAttempts.length; - - const progressParts: string[] = []; - progressParts.push(`${succeededCount}/${totalSteps} steps done`); - if (runningCount > 0) progressParts.push(`${runningCount} running`); - if (pendingCount > 0) progressParts.push(`${pendingCount} pending`); - if (failedCount > 0) progressParts.push(`${failedCount} failed`); - if (skippedCount > 0) progressParts.push(`${skippedCount} skipped`); - const progressLine = progressParts.join(" \u2022 "); - - const workersLine = activeAgentCount > 0 - ? `${activeAgentCount} agent${activeAgentCount !== 1 ? "s" : ""} active (${activeExecutorKinds.join(", ")})` - : "No agents currently active"; - - const timeline = runGraph.timeline ?? []; - const latestMeaningful = [...timeline].reverse().find( - (ev) => !NOISY_EVENT_TYPES.has(ev.eventType) - ); - const lastActionLine = latestMeaningful - ? `Last: ${narrativeForEvent(latestMeaningful)}` - : null; - - const recentEvents = timeline - .filter((ev) => !NOISY_EVENT_TYPES.has(ev.eventType)) - .slice(0, 5); - const narrativeLines = narrativeSummary(recentEvents, steeringLog); - - return ( - <div className="space-y-2"> - <div className="px-3 py-2.5" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-bold uppercase tracking-[1px] mb-2" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Timeline summary - </div> - - <div className="space-y-1"> - <div className="flex items-center gap-1.5 text-xs" style={{ color: COLORS.textSecondary }}> - <CheckCircle size={12} weight="regular" className="shrink-0" style={{ color: COLORS.success }} /> - <span>{progressLine}</span> - </div> - <div className="flex items-center gap-1.5 text-xs" style={{ color: COLORS.textSecondary }}> - <Robot size={12} weight="regular" className="shrink-0" style={{ color: COLORS.accent }} /> - <span>{workersLine}</span> - </div> - {lastActionLine && ( - <div className="flex items-center gap-1.5 text-xs" style={{ color: COLORS.textMuted }}> - <Lightning size={12} weight="regular" className="shrink-0" style={{ color: COLORS.warning }} /> - <span className="truncate">{lastActionLine}</span> - </div> - )} - </div> - </div> - - {(narrativeLines.length > 0 || steeringLog.length > 0) && ( - <div className="px-3 py-2.5" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-bold uppercase tracking-[1px] mb-1.5" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>RECENT ACTIVITY</div> - <div className="space-y-1"> - {steeringLog.map((d, i) => ( - <div key={`steer-${i}`} className="flex items-start gap-2"> - <ChatCircle size={12} weight="regular" className="shrink-0 mt-0.5" style={{ color: "#06B6D4" }} /> - <div className="flex-1 min-w-0"> - <span className="text-[11px]" style={{ color: "#06B6D4" }}>User directive: {d.directive}</span> - <span className="ml-2 text-[10px]" style={{ color: COLORS.textMuted }}>{relativeWhen(d.appliedAt)}</span> - </div> - </div> - ))} - {recentEvents.map((ev, i) => { - const Icon = iconForEventType(ev.eventType); - const hex = iconHexForEventType(ev.eventType, ev.reason); - return ( - <div key={`ev-${ev.id ?? i}`} className="flex items-start gap-2"> - <Icon className="h-3 w-3 shrink-0 mt-0.5" style={{ color: hex }} /> - <div className="flex-1 min-w-0"> - <span className="text-[11px]" style={{ color: COLORS.textSecondary }}>{narrativeForEvent(ev)}</span> - <span className="ml-2 text-[10px]" style={{ color: COLORS.textMuted }}>{relativeWhen(ev.createdAt)}</span> - </div> - </div> - ); - })} - {narrativeLines.length === 0 && steeringLog.length === 0 && ( - <div className="text-[11px]" style={{ color: COLORS.textMuted }}>Processing events...</div> - )} - </div> - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/ChatChannelList.tsx b/apps/desktop/src/renderer/components/missions/ChatChannelList.tsx deleted file mode 100644 index cb05ee46c..000000000 --- a/apps/desktop/src/renderer/components/missions/ChatChannelList.tsx +++ /dev/null @@ -1,309 +0,0 @@ -/** - * ChatChannelList — the sidebar channel list inside MissionChatV2. - * - * Renders global feed, orchestrator, teammate and worker channels - * with status dots, unread badges and phase labels. - */ -import React from "react"; -import { - Globe, - Crown, - UsersThree, - Wrench, - CaretRight, -} from "@phosphor-icons/react"; -import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; - -// ── Design tokens ── -const BG_SIDEBAR = "#1a1625"; -const ACCENT = COLORS.accent; -const BORDER = "#2a2535"; -const TEXT_PRIMARY = COLORS.textPrimary; -const TEXT_MUTED = COLORS.textMuted; -const TEXT_DIM = COLORS.textDim; -const STATUS_GREEN = COLORS.success; -const STATUS_GRAY = "#6b7280"; -const BG_PAGE = COLORS.pageBg; - -const LABEL_STYLE: React.CSSProperties = { - fontSize: 10, - fontWeight: 700, - fontFamily: SANS_FONT, - textTransform: "uppercase", - letterSpacing: "1px", - color: TEXT_MUTED, -}; - -export type ChannelKind = "global" | "orchestrator" | "teammate" | "worker"; - -export type Channel = { - id: string; - kind: ChannelKind; - label: string; - fullLabel: string; - threadId: string | null; - sessionId: string | null; - laneId: string | null; - status: "active" | "closed"; - stepKey: string | null; - attemptId: string | null; - unreadCount: number; - phaseLabel: string | null; -}; - -type ChatChannelListProps = { - channels: Channel[]; - orchestratorChannel: Channel | null; - teammateChannels: Channel[]; - activeWorkerChannels: Channel[]; - completedWorkerChannels: Channel[]; - selectedChannelId: string; - completedCollapsed: boolean; - workerStatusDot: (attemptId: string | null) => string; - onSelectChannel: (id: string) => void; - onToggleCompletedCollapsed: () => void; -}; - -export const ChatChannelList = React.memo(function ChatChannelList({ - channels, - orchestratorChannel, - teammateChannels, - activeWorkerChannels, - completedWorkerChannels, - selectedChannelId, - completedCollapsed, - workerStatusDot, - onSelectChannel, - onToggleCompletedCollapsed, -}: ChatChannelListProps) { - return ( - <aside - className="flex w-[188px] shrink-0 flex-col" - style={{ background: BG_SIDEBAR, borderRight: `1px solid ${BORDER}` }} - > - <div className="px-2.5 py-2" style={{ borderBottom: `1px solid ${BORDER}` }}> - <div style={{ ...LABEL_STYLE, color: TEXT_PRIMARY }}>Conversations</div> - </div> - - <div className="flex-1 overflow-y-auto p-1.5 space-y-0.5"> - {/* Global channel */} - <ChannelButton - icon={<Globe size={12} weight="regular" />} - label="Mission Feed" - statusColor={STATUS_GREEN} - isSelected={selectedChannelId === "global"} - onClick={() => onSelectChannel("global")} - unreadCount={0} - /> - - {/* Orchestrator section */} - {orchestratorChannel && ( - <> - <SectionLabel>ORCHESTRATOR</SectionLabel> - <ChannelButton - icon={<Crown size={12} weight="fill" />} - label="Orchestrator" - statusColor={statusDotColor(orchestratorChannel.status)} - isSelected={selectedChannelId === orchestratorChannel.id} - onClick={() => onSelectChannel(orchestratorChannel.id)} - unreadCount={orchestratorChannel.unreadCount} - /> - </> - )} - - {/* Teammates */} - {teammateChannels.length > 0 && ( - <> - <SectionLabel>TEAMMATES</SectionLabel> - {teammateChannels.map((ch) => ( - <ChannelButton - key={ch.id} - icon={<UsersThree size={12} weight="fill" />} - label={ch.label} - statusColor={statusDotColor(ch.status)} - isSelected={selectedChannelId === ch.id} - onClick={() => onSelectChannel(ch.id)} - unreadCount={ch.unreadCount} - /> - ))} - </> - )} - - {/* Active workers */} - {activeWorkerChannels.length > 0 && ( - <> - <SectionLabel>ACTIVE</SectionLabel> - {activeWorkerChannels.map((ch) => ( - <ChannelButton - key={ch.id} - icon={<Wrench size={12} weight="fill" />} - label={ch.label} - statusColor={workerStatusDot(ch.attemptId)} - isSelected={selectedChannelId === ch.id} - onClick={() => onSelectChannel(ch.id)} - unreadCount={ch.unreadCount} - badge={ch.phaseLabel ?? undefined} - /> - ))} - </> - )} - - {/* Completed workers */} - {completedWorkerChannels.length > 0 && ( - <> - <button - className="flex w-full items-center gap-1 px-2 pt-2 pb-0.5" - style={{ ...LABEL_STYLE }} - onClick={onToggleCompletedCollapsed} - > - <CaretRight - size={10} - weight="bold" - style={{ - transform: completedCollapsed ? "rotate(0deg)" : "rotate(90deg)", - transition: "transform 100ms", - }} - /> - COMPLETED ({completedWorkerChannels.length}) - </button> - {!completedCollapsed && - completedWorkerChannels.map((ch) => ( - <ChannelButton - key={ch.id} - icon={<Wrench size={12} weight="regular" />} - label={ch.label} - statusColor={STATUS_GRAY} - isSelected={selectedChannelId === ch.id} - onClick={() => onSelectChannel(ch.id)} - unreadCount={ch.unreadCount} - badge={ch.phaseLabel ?? undefined} - /> - ))} - </> - )} - - {channels.length <= 1 && ( - <div className="px-2 py-4 text-center text-[10px]" style={{ color: TEXT_MUTED }}> - No worker channels yet - </div> - )} - </div> - </aside> - ); -}); - -// ── Private helpers ── - -const STATUS_DOT: Record<string, string> = { - active: STATUS_GREEN, - closed: STATUS_GRAY, -}; - -function statusDotColor(status: string): string { - return STATUS_DOT[status] ?? STATUS_GRAY; -} - -export function formatChannelAccessibleName(label: string, badge?: string): string { - const cleanLabel = label.trim(); - const cleanBadge = badge?.trim(); - if (!cleanBadge) return cleanLabel; - return `${cleanLabel}, ${cleanBadge} channel`; -} - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( - <div - className="px-2 pt-2 pb-0.5" - style={{ - fontSize: 10, - fontWeight: 700, - fontFamily: "var(--font-sans)", - textTransform: "uppercase", - letterSpacing: "1px", - color: TEXT_DIM, - }} - > - {children} - </div> - ); -} - -function ChannelButton({ - icon, - label, - statusColor, - isSelected, - onClick, - unreadCount, - badge, - badgeColor, -}: { - icon: React.ReactNode; - label: string; - statusColor: string; - isSelected: boolean; - onClick: () => void; - unreadCount: number; - badge?: string; - badgeColor?: string; -}) { - const accessibleName = formatChannelAccessibleName(label, badge); - const badgeText = badge ? `Phase: ${badge}` : ""; - return ( - <button - onClick={onClick} - aria-label={accessibleName} - title={accessibleName} - className="flex w-full flex-col gap-0.5 px-2 py-1.5 text-left transition-colors" - style={ - isSelected - ? { background: `${ACCENT}12`, borderLeft: `3px solid ${ACCENT}`, color: TEXT_PRIMARY } - : { color: TEXT_MUTED } - } - onMouseEnter={(e) => { - if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = "#1A1720"; - }} - onMouseLeave={(e) => { - if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; - }} - > - <div className="flex w-full items-center gap-1.5"> - <span - className="inline-block h-1.5 w-1.5 shrink-0 rounded-full" - style={{ backgroundColor: statusColor }} - /> - <span className="shrink-0" style={{ color: isSelected ? ACCENT : TEXT_MUTED }}> - {icon} - </span> - <span className="truncate text-[11px]">{label}</span> - {unreadCount > 0 && ( - <span - className="ml-auto shrink-0 px-1 py-0.5 text-[9px] font-semibold" - style={{ background: ACCENT, color: BG_PAGE }} - > - {unreadCount} - </span> - )} - </div> - {badge && ( - <div className="flex items-center gap-1 pl-5" aria-hidden="true"> - {badgeColor ? ( - <span - className="inline-flex items-center gap-0.5 px-1 py-0 text-[8px] font-bold uppercase tracking-[0.5px]" - style={{ background: `${badgeColor}18`, color: badgeColor, border: `1px solid ${badgeColor}30` }} - > - {badge} - </span> - ) : ( - <span - className="inline-flex items-center px-1 py-0 text-[8px] font-bold uppercase tracking-[0.5px]" - style={{ background: `${ACCENT}12`, color: ACCENT, border: `1px solid ${ACCENT}25` }} - > - {badgeText} - </span> - )} - </div> - )} - </button> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/ChatInput.tsx b/apps/desktop/src/renderer/components/missions/ChatInput.tsx deleted file mode 100644 index eb9ef0a78..000000000 --- a/apps/desktop/src/renderer/components/missions/ChatInput.tsx +++ /dev/null @@ -1,222 +0,0 @@ -/** - * ChatInput — the input bar at the bottom of MissionChatV2. - * - * Contains quick-target chips, a contextual helper banner for - * worker channels, the MentionInput, and a "sending…" indicator. - */ -import React from "react"; -import { SpinnerGap } from "@phosphor-icons/react"; -import type { AgentChatFileRef } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; -import { MentionInput, type MentionParticipant } from "../shared/MentionInput"; -import type { Channel } from "./ChatChannelList"; - -// ── Design tokens ── -const MONO = MONO_FONT; -const BG_MAIN = COLORS.cardBg; -const BG_ELEVATED = "#130f1c"; -const BORDER = "#2a2535"; -const TEXT_PRIMARY = COLORS.textPrimary; -const TEXT_SECONDARY = COLORS.textSecondary; -const TEXT_MUTED = COLORS.textMuted; -const STATUS_GREEN = COLORS.success; -const STATUS_GRAY = "#6b7280"; -const STATUS_RED = COLORS.danger; -const WARNING = COLORS.warning; - -const LABEL_STYLE: React.CSSProperties = { - fontSize: 10, - fontWeight: 700, - fontFamily: SANS_FONT, - textTransform: "uppercase", - letterSpacing: "1px", - color: TEXT_MUTED, -}; - -export type QuickTarget = { - id: string; - label: string; - status: "active" | "completed" | "failed"; - helper: string; -}; - -export type ChatInputProps = { - selectedChannel: Channel | undefined; - input: string; - attachments: AgentChatFileRef[]; - sending: boolean; - chatBlocked: boolean; - participants: MentionParticipant[]; - quickTargets: QuickTarget[]; - onInputChange: (value: string) => void; - onSend: (message: string, mentions: string[]) => void; - onAppendMentionTarget: (targetId: string) => void; - onPickAttachments?: () => void | Promise<void>; - onRemoveAttachment?: (attachmentPath: string) => void; -}; - -export const ChatInput = React.memo(function ChatInput({ - selectedChannel, - input, - attachments, - sending, - chatBlocked, - participants, - quickTargets, - onInputChange, - onSend, - onAppendMentionTarget, - onPickAttachments, - onRemoveAttachment, -}: ChatInputProps) { - const surfaceMode = selectedChannel?.kind === "global" ? "mission-feed" : "mission-thread"; - const channelHelper = (() => { - switch (selectedChannel?.kind) { - case "global": - return "Mission feed is read-only. Open the orchestrator or a worker thread to send a message."; - case "orchestrator": - return "Talk to the orchestrator directly about priorities, recovery, and run-level decisions."; - case "teammate": - return `Send a direct note to ${selectedChannel.label} without leaving the mission thread.`; - case "worker": - return selectedChannel.status === "active" - ? `This worker thread is live. ADE will queue the note if the worker is between turns.` - : "This worker thread is history-only, so new notes will not be delivered."; - default: - return "Send guidance into the active mission thread."; - } - })(); - - const channelStateLabel = (() => { - if (!selectedChannel) return "Mission chat"; - if (selectedChannel.kind === "global") return "Mission feed"; - if (selectedChannel.kind === "orchestrator") return "Coordinator lane"; - if (selectedChannel.kind === "teammate") return "Direct teammate thread"; - return selectedChannel.status === "active" ? "Live worker thread" : "Worker history"; - })(); - - return ( - <div - className="w-full min-w-0 max-w-full overflow-hidden px-4 pb-4 pt-3" - style={{ - background: `radial-gradient(circle at top, color-mix(in srgb, var(--chat-liquid-sheen, rgba(255,255,255,0.08)) 34%, transparent) 0%, transparent 34%), linear-gradient(180deg, ${BG_MAIN} 0%, ${BG_ELEVATED} 100%)`, - }} - > - <div - className="ade-liquid-glass ade-liquid-glass-strong mb-3 flex w-full min-w-0 max-w-full flex-wrap items-start gap-3 overflow-hidden rounded-[18px] px-4 py-3" - style={{ - borderColor: `color-mix(in srgb, ${BORDER} 70%, rgba(255,255,255,0.08))`, - boxShadow: "0 22px 52px -28px rgba(4, 2, 10, 0.58)", - }} - > - <div className="min-w-0 flex-1"> - <div className="flex flex-wrap items-center gap-2"> - <span style={LABEL_STYLE}>Compose</span> - <span - className="ade-liquid-glass-pill inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em]" - style={{ - color: TEXT_PRIMARY, - fontFamily: MONO, - }} - > - {channelStateLabel} - </span> - {chatBlocked ? ( - <span - className="ade-liquid-glass-pill inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em]" - style={{ - border: `1px solid ${WARNING}28`, - background: `${WARNING}12`, - color: WARNING, - fontFamily: MONO, - }} - > - Read only - </span> - ) : null} - </div> - <div className="mt-1 text-[12px] leading-[1.55]" style={{ color: TEXT_SECONDARY }}> - {channelHelper} - </div> - </div> - - {quickTargets.length > 0 ? ( - <div className="flex min-w-0 flex-wrap justify-end gap-2"> - {quickTargets.map((target) => ( - <button - key={target.id} - type="button" - onClick={() => onAppendMentionTarget(target.id)} - disabled={sending || chatBlocked} - className="ade-liquid-glass-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[10px] transition-all hover:border-white/[0.12] hover:opacity-100 disabled:opacity-50" - style={{ - color: TEXT_PRIMARY, - fontFamily: MONO, - }} - title={target.helper} - > - <span - className="inline-block h-1.5 w-1.5 rounded-full" - style={{ - backgroundColor: - target.status === "failed" - ? STATUS_RED - : target.status === "completed" - ? STATUS_GRAY - : STATUS_GREEN, - }} - /> - <span>@{target.id}</span> - {target.label !== target.id ? ( - <span style={{ color: TEXT_MUTED }}>{target.label}</span> - ) : null} - </button> - ))} - </div> - ) : null} - </div> - - <MentionInput - value={input} - onChange={onInputChange} - onSend={onSend} - participants={participants} - attachments={attachments} - onPickAttachments={onPickAttachments} - onRemoveAttachment={onRemoveAttachment} - placeholder={(() => { - switch (selectedChannel?.kind) { - case "global": - return "Mission feed is read-only..."; - case "orchestrator": - return "Message the orchestrator..."; - case "teammate": - return `Message teammate ${selectedChannel?.label ?? ""}...`; - case "worker": - return selectedChannel.status === "active" - ? `Steer worker ${selectedChannel?.label ?? ""}...` - : `This worker thread is history-only...`; - default: - return `Message ${selectedChannel?.fullLabel ?? "worker"}...`; - } - })()} - disabled={sending || chatBlocked} - autoFocus - surfaceMode={surfaceMode} - footerHint={selectedChannel?.kind === "worker" && !chatBlocked - ? "Worker notes queue automatically if the session is busy." - : "Press Enter to send. Shift+Enter keeps a newline."} - /> - - {sending && ( - <div - className="flex items-center gap-1 px-1 pt-2 text-[10px]" - style={{ color: TEXT_MUTED, fontFamily: MONO }} - > - <SpinnerGap size={10} className="animate-spin" /> - Sending... - </div> - )} - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/ChatMessageArea.test.ts b/apps/desktop/src/renderer/components/missions/ChatMessageArea.test.ts deleted file mode 100644 index ec37b5865..000000000 --- a/apps/desktop/src/renderer/components/missions/ChatMessageArea.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldPollRawMissionTranscript } from "./ChatMessageArea"; -import { formatChannelAccessibleName, type Channel } from "./ChatChannelList"; - -function channel(overrides: Partial<Channel>): Channel { - return { - id: "thread:1", - kind: "worker", - label: "Worker", - fullLabel: "Worker", - threadId: "thread-1", - sessionId: "session-1", - laneId: null, - status: "active", - stepKey: null, - attemptId: null, - unreadCount: 0, - phaseLabel: null, - ...overrides, - }; -} - -describe("shouldPollRawMissionTranscript", () => { - it("polls raw transcripts only for live worker-style threads", () => { - expect(shouldPollRawMissionTranscript(channel({ kind: "worker", status: "active" }))).toBe(true); - expect(shouldPollRawMissionTranscript(channel({ kind: "teammate", status: "active" }))).toBe(true); - }); - - it("does not poll raw coordinator transcripts", () => { - expect(shouldPollRawMissionTranscript(channel({ kind: "orchestrator", status: "active" }))).toBe(false); - }); - - it("does not poll closed or feed channels", () => { - expect(shouldPollRawMissionTranscript(channel({ kind: "worker", status: "closed" }))).toBe(false); - expect(shouldPollRawMissionTranscript(channel({ kind: "global", status: "active", threadId: null, sessionId: null }))).toBe(false); - }); -}); - -describe("formatChannelAccessibleName", () => { - it("keeps worker labels and phase badges distinct for assistive tech", () => { - expect(formatChannelAccessibleName("data model", "Data")).toBe("data model, Data channel"); - }); - - it("does not append empty badges", () => { - expect(formatChannelAccessibleName("Orchestrator")).toBe("Orchestrator"); - expect(formatChannelAccessibleName("Mission Feed", " ")).toBe("Mission Feed"); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx b/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx deleted file mode 100644 index b6323e385..000000000 --- a/apps/desktop/src/renderer/components/missions/ChatMessageArea.tsx +++ /dev/null @@ -1,474 +0,0 @@ -/** - * ChatMessageArea — the main message display area inside MissionChatV2. - * - * Renders a channel header bar, notice / blocked banners, and the - * message list (delegating to MissionThreadMessageList). - */ -import React, { useMemo } from "react"; -import { - Crown, - UsersThree, - Wrench, - Globe, -} from "@phosphor-icons/react"; -import type { - MissionIntervention, - OrchestratorChatMessage, -} from "../../../shared/types"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; -import { relativeWhen } from "../../lib/format"; -import { MissionThreadMessageList } from "./MissionThreadMessageList"; -import type { Channel } from "./ChatChannelList"; -import { getMissionInterventionOwnerLabel } from "./missionHelpers"; -import type { MissionStateNarrative } from "./missionFeedPresentation"; - -// ── Design tokens ── -const MONO = MONO_FONT; -const BORDER = "#2a2535"; -const TEXT_PRIMARY = COLORS.textPrimary; -const TEXT_SECONDARY = COLORS.textSecondary; -const TEXT_MUTED = COLORS.textMuted; -const STATUS_GREEN = COLORS.success; -const STATUS_GRAY = "#6b7280"; -const STATUS_RED = COLORS.danger; -const WARNING = COLORS.warning; - -const STATUS_DOT: Record<string, string> = { - active: STATUS_GREEN, - closed: STATUS_GRAY, - failed: STATUS_RED, -}; - -type ChatNotice = { reason: string; action: string } | null; - -export type ChatMessageAreaProps = { - selectedChannel: Channel | undefined; - workerStatusDot: (attemptId: string | null) => string; - displayMessages: OrchestratorChatMessage[]; - attemptNameMap: Map<string, string>; - jumpNotice: string | null; - chatNotice: ChatNotice; - chatBlocked: ChatNotice; - threadIntervention: MissionIntervention | null; - onOpenIntervention: (interventionId: string) => void; - showStreamingIndicator: boolean; - threadMessagesLoading: boolean; - threadMessagesLoadingMore: boolean; - threadMessagesError: string | null; - threadMessagesHasMore: boolean; - onLoadOlderMessages: () => void; - onRetryMessages: () => void; - missionNarrative: MissionStateNarrative | null; - runControls?: React.ReactNode; - onApproval: ( - sessionId: string, - itemId: string, - decision: "accept" | "accept_for_session" | "decline" | "cancel", - responseText?: string | null, - answers?: Record<string, string | string[]>, - ) => void; -}; - -export function shouldPollRawMissionTranscript(channel: Channel | undefined): boolean { - if (!channel || channel.status !== "active") return false; - return channel.kind === "worker" || channel.kind === "teammate"; -} - -export const ChatMessageArea = React.memo(function ChatMessageArea({ - selectedChannel, - workerStatusDot, - displayMessages, - attemptNameMap, - jumpNotice, - chatNotice, - chatBlocked, - threadIntervention, - onOpenIntervention, - showStreamingIndicator, - threadMessagesLoading, - threadMessagesLoadingMore, - threadMessagesError, - threadMessagesHasMore, - onLoadOlderMessages, - onRetryMessages, - missionNarrative, - runControls, - onApproval, -}: ChatMessageAreaProps) { - const threadInterventionOwnerLabel = useMemo( - () => getMissionInterventionOwnerLabel(threadIntervention), - [threadIntervention], - ); - const channelHeaderName = (() => { - if (!selectedChannel) return "..."; - switch (selectedChannel.kind) { - case "global": - return "Mission Feed"; - case "orchestrator": - return "Orchestrator"; - default: - return selectedChannel.fullLabel; - } - })(); - const channelSummary = (() => { - if (!selectedChannel) return "Mission chat"; - switch (selectedChannel.kind) { - case "global": - return "Read-only status updates from ADE, the coordinator, workers, and recovery flow."; - case "orchestrator": - return "Messages here go to the mission coordinator. Worker threads stay separate."; - case "teammate": - return "Messages here go to this teammate thread only."; - case "worker": - return selectedChannel.status === "active" - ? "Messages here go to this worker while it is running in the mission lane." - : "Read-only worker transcript. Send new direction to the coordinator or rerun."; - default: - return "Mission chat"; - } - })(); - const headerIcon = (() => { - switch (selectedChannel?.kind) { - case "global": - return <Globe size={16} weight="fill" />; - case "orchestrator": - return <Crown size={16} weight="fill" />; - case "teammate": - return <UsersThree size={16} weight="fill" />; - case "worker": - return <Wrench size={16} weight="fill" />; - default: - return <Globe size={16} weight="fill" />; - } - })(); - const headerStatusColor = - selectedChannel?.kind === "orchestrator" - ? STATUS_DOT[selectedChannel.status] ?? STATUS_GRAY - : workerStatusDot(selectedChannel?.attemptId ?? null); - const channelRoute = (() => { - if (!selectedChannel || selectedChannel.kind === "global") { - return { - label: "Read-only feed", - detail: "Mission Feed summarizes events; it does not send messages to an agent.", - tone: "muted" as const, - }; - } - if (chatBlocked || selectedChannel.status !== "active") { - return { - label: "Transcript only", - detail: chatBlocked ? `${chatBlocked.reason} ${chatBlocked.action}` : "This thread is no longer live.", - tone: "muted" as const, - }; - } - if (selectedChannel.kind === "orchestrator") { - return { - label: "Sends to coordinator", - detail: "Messages in this thread are sent to the mission coordinator.", - tone: "active" as const, - }; - } - if (selectedChannel.kind === "teammate") { - return { - label: "Sends to teammate", - detail: "Messages in this thread are sent to this teammate only.", - tone: "active" as const, - }; - } - return { - label: "Sends to worker", - detail: "Messages in this thread are sent to this worker only.", - tone: "active" as const, - }; - })(); - - return ( - <> - {/* Header */} - <div - className="min-w-0 max-w-full overflow-hidden border-b px-4 py-4" - style={{ - borderBottomColor: BORDER, - background: "linear-gradient(180deg, rgba(20,16,29,0.96) 0%, rgba(13,10,20,0.92) 100%)", - }} - > - <div className="grid grid-cols-[auto,minmax(0,1fr)] items-start gap-x-3 gap-y-2"> - <div - className="flex h-10 w-10 shrink-0 items-center justify-center border" - style={{ - borderColor: "color-mix(in srgb, var(--chat-accent) 18%, rgba(255,255,255,0.08))", - background: "color-mix(in srgb, var(--chat-accent) 10%, transparent)", - color: TEXT_PRIMARY, - }} - > - {headerIcon} - </div> - <div className="min-w-0 flex-1"> - <div className="flex min-w-0 flex-wrap items-center gap-2"> - <span - className="min-w-0 max-w-full truncate text-[13px] font-semibold" - style={{ color: TEXT_PRIMARY, fontFamily: MONO }} - title={channelHeaderName} - > - {channelHeaderName} - </span> - {selectedChannel && selectedChannel.kind !== "global" ? ( - <span - className="inline-block h-2 w-2 rounded-full" - style={{ backgroundColor: headerStatusColor }} - /> - ) : null} - <ChannelKindBadge channel={selectedChannel} /> - <ChannelRouteBadge route={channelRoute} /> - </div> - <div className="mt-1 text-[12px] leading-[1.55]" style={{ color: TEXT_SECONDARY }}> - {channelSummary} - </div> - </div> - <div className="col-start-2 flex min-w-0 flex-wrap gap-1.5"> - {runControls} - </div> - </div> - </div> - - {missionNarrative && selectedChannel?.kind === "global" && ( - <div - className="flex min-w-0 max-w-full items-start gap-3 overflow-hidden px-3 py-2" - style={{ - borderBottom: `1px solid ${BORDER}`, - background: `${severityColorForNarrative(missionNarrative.severity)}10`, - }} - > - <span - className="mt-1 inline-block h-2 w-2 rounded-full" - style={{ backgroundColor: severityColorForNarrative(missionNarrative.severity) }} - /> - <div className="min-w-0 flex-1"> - <div - className="text-[10px] font-bold uppercase tracking-[0.12em]" - style={{ color: TEXT_MUTED, fontFamily: MONO }} - > - Mission state - </div> - <div className="mt-0.5 text-[11px] font-semibold" style={{ color: TEXT_PRIMARY }}> - {missionNarrative.title} - </div> - <div className="mt-0.5 text-[11px] leading-[1.45]" style={{ color: TEXT_SECONDARY }}> - {missionNarrative.detail} - </div> - </div> - {missionNarrative.at ? ( - <span className="shrink-0 text-[10px]" style={{ color: TEXT_MUTED }}> - {relativeWhen(missionNarrative.at)} - </span> - ) : null} - </div> - )} - - {jumpNotice && ( - <div - className="min-w-0 max-w-full overflow-hidden px-3 py-1.5 text-[10px]" - style={{ borderBottom: `1px solid ${WARNING}30`, background: `${WARNING}12`, color: WARNING }} - > - {jumpNotice} - </div> - )} - - {threadIntervention && ( - <div - className="flex min-w-0 max-w-full items-start justify-between gap-3 overflow-hidden px-3 py-2" - style={{ borderBottom: `1px solid ${WARNING}30`, background: `${WARNING}10` }} - > - <div className="min-w-0"> - <div - className="text-[10px] font-bold uppercase tracking-[0.12em]" - style={{ color: WARNING, fontFamily: MONO }} - > - {threadInterventionOwnerLabel - ? `${threadInterventionOwnerLabel} needs attention` - : "This conversation needs attention"} - </div> - <div className="mt-1 text-[11px] font-semibold" style={{ color: TEXT_PRIMARY }}> - {threadIntervention.title} - </div> - <div className="mt-1 text-[11px] leading-[1.45]" style={{ color: TEXT_SECONDARY }}> - {threadIntervention.requestedAction?.trim() || threadIntervention.body} - </div> - </div> - <button - type="button" - className="shrink-0 border px-2 py-1 font-mono text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ color: WARNING, borderColor: `${WARNING}35`, background: `${WARNING}10` }} - onClick={() => onOpenIntervention(threadIntervention.id)} - > - Open - </button> - </div> - )} - - {/* Runtime availability banner */} - {(chatNotice || chatBlocked) && ( - <div - style={{ - background: `${WARNING}12`, - borderBottom: `1px solid ${WARNING}30`, - padding: "6px 12px", - fontFamily: MONO, - fontSize: "10px", - color: WARNING, - display: "flex", - alignItems: "center", - gap: "8px", - minWidth: 0, - maxWidth: "100%", - overflow: "hidden", - }} - > - <span style={{ fontSize: "14px" }}>{"\u26A0"}</span> - <span className="min-w-0 break-words"> - {(chatBlocked ?? chatNotice)?.reason} {(chatBlocked ?? chatNotice)?.action} - </span> - </div> - )} - - {selectedChannel && selectedChannel.kind !== "global" && (threadMessagesLoading || threadMessagesError || threadMessagesHasMore) ? ( - <div - className="flex min-w-0 max-w-full items-center justify-between gap-3 overflow-hidden px-3 py-1.5 text-[10px]" - style={{ - borderBottom: `1px solid ${BORDER}`, - background: "rgba(9, 7, 14, 0.62)", - color: TEXT_MUTED, - fontFamily: MONO, - }} - > - <span className="min-w-0 truncate"> - {threadStatusLabel(threadMessagesLoading, threadMessagesError, threadMessagesHasMore)} - </span> - <div className="flex shrink-0 items-center gap-2"> - {threadMessagesError ? ( - <button - type="button" - className="border px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.12em]" - style={{ borderColor: `${WARNING}45`, color: WARNING }} - onClick={onRetryMessages} - > - Retry - </button> - ) : null} - {threadMessagesHasMore ? ( - <button - type="button" - className="border px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.12em] disabled:opacity-55" - style={{ borderColor: "color-mix(in srgb, var(--chat-accent) 30%, transparent)", color: "var(--chat-accent)" }} - onClick={onLoadOlderMessages} - disabled={threadMessagesLoadingMore} - aria-label={threadMessagesLoadingMore ? "Loading older mission thread messages" : "Load older mission thread messages"} - > - {threadMessagesLoadingMore ? "Loading..." : "Load older"} - </button> - ) : null} - </div> - </div> - ) : null} - - {/* Message list */} - <MissionThreadMessageList - messages={displayMessages} - sessionId={selectedChannel?.sessionId ?? null} - showStreamingIndicator={ - selectedChannel?.kind === "global" ? false : showStreamingIndicator - } - transcriptPollingEnabled={shouldPollRawMissionTranscript(selectedChannel)} - className="flex-1" - onApproval={onApproval} - /> - </> - ); -}); - -// ── Small helper components ── - -function threadStatusLabel(loading: boolean, error: string | null, hasMore: boolean): string { - if (loading) return "Loading thread messages..."; - if (error) return error; - if (hasMore) return "Older messages available."; - return ""; -} - -function workerBadgeLabel(status: string, phaseLabel: string | null): string { - const suffix = status === "active" ? "worker" : "history"; - const fallback = status === "active" ? "Active worker" : "Worker history"; - return phaseLabel ? `${phaseLabel} ${suffix}` : fallback; -} - -function ChannelKindBadge({ channel }: { channel: Channel | undefined }) { - if (!channel) return null; - if (channel.kind === "orchestrator") { - return ( - <span - className="inline-flex items-center gap-1 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ background: "#3B82F618", color: "#60A5FA", border: "1px solid #3B82F630" }} - > - <Crown size={10} weight="fill" /> - Orchestrator - </span> - ); - } - if (channel.kind === "teammate") { - return ( - <span - className="inline-flex items-center gap-1 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ background: "#06B6D418", color: "#06B6D4", border: "1px solid #06B6D430" }} - > - <UsersThree size={10} weight="fill" /> - Teammate - </span> - ); - } - if (channel.kind === "worker") { - return ( - <span - className="inline-flex items-center gap-1 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ background: "#8B5CF618", color: "#8B5CF6", border: "1px solid #8B5CF630" }} - > - <Wrench size={10} weight="fill" /> - {workerBadgeLabel(channel.status, channel.phaseLabel)} - </span> - ); - } - return null; -} - -function ChannelRouteBadge({ - route, -}: { - route: { label: string; detail: string; tone: "active" | "muted" }; -}) { - const active = route.tone === "active"; - return ( - <span - className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ - background: active ? "#22C55E18" : "#6B728018", - color: active ? "#22C55E" : TEXT_MUTED, - border: `1px solid ${active ? "#22C55E30" : "#6B728030"}`, - fontFamily: MONO, - }} - title={route.detail} - > - {route.label} - </span> - ); -} - -function severityColorForNarrative(severity: MissionStateNarrative["severity"]): string { - switch (severity) { - case "success": - return COLORS.success; - case "warning": - return COLORS.warning; - case "error": - return COLORS.danger; - default: - return COLORS.accent; - } -} diff --git a/apps/desktop/src/renderer/components/missions/ClarificationQuizModal.tsx b/apps/desktop/src/renderer/components/missions/ClarificationQuizModal.tsx deleted file mode 100644 index d927f4dc0..000000000 --- a/apps/desktop/src/renderer/components/missions/ClarificationQuizModal.tsx +++ /dev/null @@ -1,628 +0,0 @@ -import React, { useState, useCallback, useMemo } from "react"; -import { X, CaretLeft, CaretRight, CheckCircle, Warning, Question, Flag } from "@phosphor-icons/react"; -import type { - ClarificationQuestion, - ClarificationAnswer, - ClarificationQuiz, - MissionInterventionResolutionKind, -} from "../../../shared/types"; -import { - COLORS, - MONO_FONT, - SANS_FONT, - LABEL_STYLE, - primaryButton, - outlineButton, -} from "../lanes/laneDesignTokens"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type ClarificationQuizModalProps = { - interventionId: string; - missionId: string; - questions: ClarificationQuestion[]; - phase?: string | null; - onSubmit: (quiz: ClarificationQuiz, resolutionKind?: MissionInterventionResolutionKind) => Promise<void>; - onClose: () => void; -}; - -type DraftAnswer = { - text: string; - selectedOption: string | null; - markedConfusing: boolean; - useDefault: boolean; -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function ClarificationQuizModal({ - questions, - phase, - onSubmit, - onClose, -}: ClarificationQuizModalProps) { - const [currentIndex, setCurrentIndex] = useState(0); - const [drafts, setDrafts] = useState<DraftAnswer[]>(() => - questions.map(() => ({ - text: "", - selectedOption: null, - markedConfusing: false, - useDefault: false, - })) - ); - const [submitting, setSubmitting] = useState(false); - const [showSummary, setShowSummary] = useState(false); - - const currentQuestion = questions[currentIndex]; - const currentDraft = drafts[currentIndex]; - const total = questions.length; - - const updateDraft = useCallback((index: number, patch: Partial<DraftAnswer>) => { - setDrafts((prev) => { - const next = [...prev]; - next[index] = { ...next[index], ...patch }; - return next; - }); - }, []); - - const hasAnswer = useCallback((index: number) => { - const d = drafts[index]; - return d.useDefault || d.text.trim().length > 0 || d.selectedOption !== null; - }, [drafts]); - - const allAnswered = useMemo(() => - drafts.every((_, i) => hasAnswer(i)), - [drafts, hasAnswer] - ); - - const answeredCount = useMemo(() => - drafts.filter((_, i) => hasAnswer(i)).length, - [drafts, hasAnswer] - ); - const canAcceptDefaults = useMemo( - () => questions.every((question) => typeof question.defaultAssumption === "string" && question.defaultAssumption.trim().length > 0), - [questions], - ); - - const handleNext = useCallback(() => { - if (currentIndex < total - 1) { - setCurrentIndex(currentIndex + 1); - } else { - setShowSummary(true); - } - }, [currentIndex, total]); - - const handleBack = useCallback(() => { - if (showSummary) { - setShowSummary(false); - } else if (currentIndex > 0) { - setCurrentIndex(currentIndex - 1); - } - }, [currentIndex, showSummary]); - - const buildQuiz = useCallback((forceDefaults = false): ClarificationQuiz => { - const answers: ClarificationAnswer[] = drafts.map((d, i) => ({ - questionIndex: i, - answer: forceDefaults - ? (questions[i].defaultAssumption ?? "") - : d.useDefault - ? (questions[i].defaultAssumption ?? "") - : (d.selectedOption ?? d.text.trim()), - source: forceDefaults || d.useDefault ? "default_assumption" as const : "user" as const, - markedConfusing: d.markedConfusing || undefined, - })); - return { - questions, - answers, - phase: phase ?? undefined, - submittedAt: new Date().toISOString(), - }; - }, [drafts, phase, questions]); - - const handleSubmit = useCallback(async () => { - setSubmitting(true); - try { - await onSubmit(buildQuiz(false), "answer_provided"); - } finally { - setSubmitting(false); - } - }, [buildQuiz, onSubmit]); - - const handleResolveAction = useCallback(async (resolutionKind: MissionInterventionResolutionKind) => { - setSubmitting(true); - try { - const quiz = buildQuiz(resolutionKind === "accept_defaults"); - await onSubmit(quiz, resolutionKind); - } finally { - setSubmitting(false); - } - }, [buildQuiz, onSubmit]); - - // Overlay backdrop - return ( - <div - style={{ - position: "fixed", - inset: 0, - zIndex: 9999, - display: "flex", - alignItems: "center", - justifyContent: "center", - background: "rgba(0,0,0,0.7)", - }} - onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} - > - <div - style={{ - background: COLORS.pageBg, - border: `1px solid ${COLORS.border}`, - width: "100%", - maxWidth: 640, - maxHeight: "85vh", - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} - > - {/* ── Header ── */} - <div - style={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "12px 16px", - borderBottom: `1px solid ${COLORS.border}`, - background: COLORS.cardBg, - }} - > - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - <Question weight="bold" style={{ color: COLORS.accent, width: 16, height: 16 }} /> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 11, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "1px", - color: COLORS.accent, - }} - > - Planning Questions - </span> - </div> - <div style={{ display: "flex", alignItems: "center", gap: 12 }}> - <span style={{ fontFamily: MONO_FONT, fontSize: 10, color: COLORS.textMuted }}> - {answeredCount}/{total} answered - </span> - <button - onClick={onClose} - style={{ color: COLORS.textMuted, background: "none", border: "none", cursor: "pointer", padding: 0 }} - > - <X style={{ width: 14, height: 14 }} /> - </button> - </div> - </div> - - {/* ── Phase badge ── */} - {phase && ( - <div style={{ padding: "8px 16px", borderBottom: `1px solid ${COLORS.border}` }}> - <span - style={{ - ...LABEL_STYLE, - color: COLORS.textDim, - }} - > - PHASE: {phase.toUpperCase()} - </span> - </div> - )} - - {/* ── Body ── */} - <div style={{ flex: 1, overflow: "auto", padding: 16 }}> - {showSummary ? ( - <SummaryView - questions={questions} - drafts={drafts} - onEditQuestion={(i) => { setShowSummary(false); setCurrentIndex(i); }} - /> - ) : ( - <QuestionView - question={currentQuestion} - draft={currentDraft} - index={currentIndex} - total={total} - onUpdateDraft={(patch) => updateDraft(currentIndex, patch)} - /> - )} - </div> - - {/* ── Footer / Navigation ── */} - <div - style={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "12px 16px", - borderTop: `1px solid ${COLORS.border}`, - background: COLORS.cardBg, - }} - > - <button - style={outlineButton({ - opacity: (currentIndex === 0 && !showSummary) ? 0.4 : 1, - cursor: (currentIndex === 0 && !showSummary) ? "not-allowed" : "pointer", - })} - disabled={currentIndex === 0 && !showSummary} - onClick={handleBack} - > - <CaretLeft style={{ width: 12, height: 12 }} /> - BACK - </button> - - <span style={{ fontFamily: MONO_FONT, fontSize: 10, color: COLORS.textMuted }}> - {showSummary ? "REVIEW" : `${currentIndex + 1} of ${total}`} - </span> - - {showSummary ? ( - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - <button - style={outlineButton({ - opacity: (!canAcceptDefaults || submitting) ? 0.5 : 1, - cursor: (!canAcceptDefaults || submitting) ? "not-allowed" : "pointer", - })} - disabled={!canAcceptDefaults || submitting} - onClick={() => void handleResolveAction("accept_defaults")} - > - USE DEFAULTS - </button> - <button - style={outlineButton({ - opacity: submitting ? 0.5 : 1, - cursor: submitting ? "not-allowed" : "pointer", - })} - disabled={submitting} - onClick={() => void handleResolveAction("skip_question")} - > - SKIP QUESTION - </button> - <button - style={outlineButton({ - opacity: submitting ? 0.5 : 1, - cursor: submitting ? "not-allowed" : "pointer", - })} - disabled={submitting} - onClick={() => void handleResolveAction("cancel_run")} - > - CANCEL RUN - </button> - <button - style={primaryButton({ - opacity: (!allAnswered || submitting) ? 0.5 : 1, - cursor: (!allAnswered || submitting) ? "not-allowed" : "pointer", - })} - disabled={!allAnswered || submitting} - onClick={() => void handleSubmit()} - > - <CheckCircle style={{ width: 12, height: 12 }} /> - {submitting ? "SUBMITTING..." : "SUBMIT ANSWERS"} - </button> - </div> - ) : ( - <button - style={primaryButton()} - onClick={handleNext} - > - {currentIndex < total - 1 ? "NEXT" : "REVIEW"} - <CaretRight style={{ width: 12, height: 12 }} /> - </button> - )} - </div> - </div> - </div> - ); -} - -// --------------------------------------------------------------------------- -// QuestionView — single question page -// --------------------------------------------------------------------------- - -function QuestionView({ - question, - draft, - index, - total, - onUpdateDraft, -}: { - question: ClarificationQuestion; - draft: DraftAnswer; - index: number; - total: number; - onUpdateDraft: (patch: Partial<DraftAnswer>) => void; -}) { - return ( - <div style={{ display: "flex", flexDirection: "column", gap: 12 }}> - {/* Question counter + text */} - <div> - <span style={{ ...LABEL_STYLE, color: COLORS.textDim }}> - QUESTION {index + 1} OF {total} - </span> - <div - style={{ - fontFamily: SANS_FONT, - fontSize: 15, - fontWeight: 600, - color: COLORS.textPrimary, - lineHeight: 1.5, - marginTop: 6, - }} - > - {question.question} - </div> - </div> - - {/* Context info box */} - {question.context && ( - <div - style={{ - background: "color-mix(in srgb, var(--color-info) 5%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-info) 25%, transparent)", - padding: "8px 12px", - }} - > - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.info, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.5px" }}> - CONTEXT - </span> - <div style={{ fontFamily: SANS_FONT, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.5, marginTop: 4 }}> - {question.context} - </div> - </div> - )} - - {/* Impact warning box */} - {question.impact && ( - <div - style={{ - background: "color-mix(in srgb, var(--color-warning) 5%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-warning) 25%, transparent)", - padding: "8px 12px", - display: "flex", - alignItems: "flex-start", - gap: 8, - }} - > - <Warning weight="bold" style={{ color: COLORS.warning, width: 14, height: 14, flexShrink: 0, marginTop: 2 }} /> - <div> - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.warning, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.5px" }}> - IMPACT - </span> - <div style={{ fontFamily: SANS_FONT, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.5, marginTop: 2 }}> - {question.impact} - </div> - </div> - </div> - )} - - {/* Multiple choice options */} - {question.options && question.options.length > 0 && ( - <div style={{ display: "flex", flexDirection: "column", gap: 6 }}> - <span style={{ ...LABEL_STYLE, color: COLORS.textDim }}>OPTIONS</span> - <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> - {question.options.map((opt, i) => { - const isSelected = draft.selectedOption === opt; - return ( - <button - key={i} - onClick={() => { - onUpdateDraft({ - selectedOption: isSelected ? null : opt, - useDefault: false, - }); - }} - style={{ - display: "inline-flex", - alignItems: "center", - padding: "6px 14px", - fontSize: 12, - fontFamily: MONO_FONT, - fontWeight: 600, - color: isSelected ? COLORS.accent : COLORS.textSecondary, - background: isSelected ? "color-mix(in srgb, var(--color-accent) 18%, transparent)" : COLORS.recessedBg, - border: `1px solid ${isSelected ? COLORS.accent : COLORS.outlineBorder}`, - cursor: "pointer", - transition: "all 0.15s ease", - }} - > - {opt} - </button> - ); - })} - </div> - </div> - )} - - {/* Free text area */} - <div style={{ display: "flex", flexDirection: "column", gap: 4 }}> - <span style={{ ...LABEL_STYLE, color: COLORS.textDim }}> - {question.options?.length ? "OR TYPE YOUR ANSWER" : "YOUR ANSWER"} - </span> - <textarea - rows={3} - placeholder="Type your answer here..." - value={draft.text} - onChange={(e) => onUpdateDraft({ text: e.target.value, useDefault: false })} - style={{ - display: "block", - width: "100%", - resize: "vertical", - background: COLORS.cardBg, - border: `1px solid ${COLORS.outlineBorder}`, - color: COLORS.textPrimary, - fontFamily: SANS_FONT, - fontSize: 12, - padding: "8px 10px", - lineHeight: 1.5, - outline: "none", - }} - onFocus={(e) => { e.currentTarget.style.borderColor = COLORS.accent; }} - onBlur={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; }} - /> - </div> - - {/* Default assumption hint + use default button */} - {question.defaultAssumption && ( - <div - style={{ - background: COLORS.recessedBg, - border: `1px solid ${draft.useDefault ? "color-mix(in srgb, var(--color-success) 40%, transparent)" : COLORS.outlineBorder}`, - padding: "8px 12px", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - }} - > - <div style={{ flex: 1 }}> - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.textMuted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.5px" }}> - DEFAULT ASSUMPTION - </span> - <div style={{ fontFamily: SANS_FONT, fontSize: 11, color: COLORS.textSecondary, lineHeight: 1.4, marginTop: 2 }}> - {question.defaultAssumption} - </div> - </div> - <button - onClick={() => onUpdateDraft({ useDefault: !draft.useDefault, selectedOption: null, text: "" })} - style={outlineButton({ - height: 26, - padding: "0 10px", - fontSize: 9, - ...(draft.useDefault ? { color: COLORS.success, borderColor: "color-mix(in srgb, var(--color-success) 40%, transparent)" } : {}), - })} - > - {draft.useDefault ? "DEFAULT SELECTED" : "USE DEFAULT"} - </button> - </div> - )} - - {/* Mark as confusing */} - <div style={{ display: "flex", justifyContent: "flex-end" }}> - <button - onClick={() => onUpdateDraft({ markedConfusing: !draft.markedConfusing })} - style={{ - display: "inline-flex", - alignItems: "center", - gap: 4, - padding: "4px 8px", - fontSize: 9, - fontFamily: MONO_FONT, - fontWeight: 600, - textTransform: "uppercase", - letterSpacing: "0.5px", - color: draft.markedConfusing ? COLORS.warning : COLORS.textDim, - background: draft.markedConfusing ? "color-mix(in srgb, var(--color-warning) 12%, transparent)" : "transparent", - border: `1px solid ${draft.markedConfusing ? "color-mix(in srgb, var(--color-warning) 30%, transparent)" : "transparent"}`, - cursor: "pointer", - }} - > - <Flag weight={draft.markedConfusing ? "fill" : "regular"} style={{ width: 10, height: 10 }} /> - {draft.markedConfusing ? "MARKED CONFUSING" : "MARK AS CONFUSING"} - </button> - </div> - </div> - ); -} - -// --------------------------------------------------------------------------- -// SummaryView — review all answers before submit -// --------------------------------------------------------------------------- - -function SummaryView({ - questions, - drafts, - onEditQuestion, -}: { - questions: ClarificationQuestion[]; - drafts: DraftAnswer[]; - onEditQuestion: (index: number) => void; -}) { - return ( - <div style={{ display: "flex", flexDirection: "column", gap: 12 }}> - <div> - <span style={{ ...LABEL_STYLE, color: COLORS.accent }}>REVIEW YOUR ANSWERS</span> - <div style={{ fontFamily: SANS_FONT, fontSize: 12, color: COLORS.textSecondary, marginTop: 4, lineHeight: 1.5 }}> - Review your answers below before submitting. Click any question to edit. - </div> - </div> - - {questions.map((q, i) => { - const d = drafts[i]; - const answerText = d.useDefault - ? (q.defaultAssumption ?? "(no default)") - : ((d.selectedOption ?? d.text.trim()) || "(no answer)"); - const hasAns = d.useDefault || d.text.trim().length > 0 || d.selectedOption !== null; - - return ( - <div - key={i} - onClick={() => onEditQuestion(i)} - style={{ - background: hasAns ? "color-mix(in srgb, var(--color-success) 8%, transparent)" : COLORS.recessedBg, - border: `1px solid ${hasAns ? "color-mix(in srgb, var(--color-success) 30%, transparent)" : COLORS.border}`, - padding: 12, - cursor: "pointer", - transition: "border-color 0.15s ease", - }} - > - <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}> - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - <div - style={{ - width: 20, - height: 20, - display: "flex", - alignItems: "center", - justifyContent: "center", - background: hasAns ? "color-mix(in srgb, var(--color-success) 20%, transparent)" : "color-mix(in srgb, var(--color-accent) 18%, transparent)", - border: `1px solid ${hasAns ? "color-mix(in srgb, var(--color-success) 40%, transparent)" : COLORS.accentBorder}`, - fontFamily: MONO_FONT, - fontSize: 9, - fontWeight: 700, - color: hasAns ? COLORS.success : COLORS.accent, - flexShrink: 0, - }} - > - {hasAns ? <CheckCircle style={{ width: 12, height: 12 }} /> : `${i + 1}`} - </div> - <span style={{ fontFamily: SANS_FONT, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}> - {q.question} - </span> - </div> - {d.markedConfusing && ( - <Flag weight="fill" style={{ color: COLORS.warning, width: 12, height: 12, flexShrink: 0 }} /> - )} - </div> - - <div style={{ marginTop: 6, marginLeft: 28 }}> - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: d.useDefault ? COLORS.textMuted : COLORS.textDim, textTransform: "uppercase", letterSpacing: "0.5px" }}> - {d.useDefault ? "DEFAULT:" : "ANSWER:"} - </span> - <div style={{ - fontFamily: SANS_FONT, - fontSize: 11, - color: hasAns ? COLORS.textSecondary : COLORS.textDim, - fontStyle: hasAns ? "normal" : "italic", - lineHeight: 1.4, - marginTop: 2, - }}> - {answerText} - </div> - </div> - </div> - ); - })} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/CompletionBanner.tsx b/apps/desktop/src/renderer/components/missions/CompletionBanner.tsx deleted file mode 100644 index e3e0fbcef..000000000 --- a/apps/desktop/src/renderer/components/missions/CompletionBanner.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useState } from "react"; -import { CaretDown, CaretRight } from "@phosphor-icons/react"; -import type { RunCompletionEvaluation, OrchestratorRunStatus } from "../../../shared/types"; -import { cn } from "../ui/cn"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; - -type CompletionBannerProps = { - status: OrchestratorRunStatus; - evaluation?: RunCompletionEvaluation | null; - runId?: string | null; - className?: string; -}; - -const BANNER_CONFIG: Partial<Record<OrchestratorRunStatus, { - bg: string; - border: string; - color: string; - label: string; -}>> = { - succeeded: { bg: "color-mix(in srgb, var(--color-success) 8%, transparent)", border: "color-mix(in srgb, var(--color-success) 30%, transparent)", color: COLORS.success, label: "Completed" }, - failed: { bg: "color-mix(in srgb, var(--color-error) 8%, transparent)", border: "color-mix(in srgb, var(--color-error) 30%, transparent)", color: COLORS.danger, label: "Failed" }, - paused: { bg: "color-mix(in srgb, var(--color-warning) 8%, transparent)", border: "color-mix(in srgb, var(--color-warning) 30%, transparent)", color: COLORS.warning, label: "Paused" }, - canceled: { bg: "#71717A08", border: "#71717A30", color: "#71717A", label: "Canceled" }, -}; - -export function CompletionBanner({ status, evaluation, className }: CompletionBannerProps) { - const [expanded, setExpanded] = useState(false); - - const config = BANNER_CONFIG[status]; - if (!config) return null; - - const blockingDiagnostics = status !== "canceled" ? (evaluation?.diagnostics?.filter((d) => d.blocking) ?? []) : []; - const riskFactors = status !== "canceled" ? (evaluation?.riskFactors ?? []) : []; - const hasDetails = blockingDiagnostics.length > 0 || riskFactors.length > 0; - - return ( - <div - className={cn("px-3 py-2", className)} - style={{ background: config.bg, borderLeft: `3px solid ${config.border}` }} - > - <div className="flex items-center justify-between"> - <span - className="text-[11px] font-semibold uppercase tracking-wide" - style={{ color: config.color, fontFamily: MONO_FONT }} - > - {config.label} - </span> - - {hasDetails && ( - <button - type="button" - onClick={() => setExpanded((p) => !p)} - className="flex items-center gap-1 text-[9px]" - style={{ color: COLORS.textDim, fontFamily: MONO_FONT, background: "none", border: "none", cursor: "pointer", padding: 0 }} - > - {expanded ? <CaretDown weight="bold" className="h-2.5 w-2.5" /> : <CaretRight weight="bold" className="h-2.5 w-2.5" />} - Details - </button> - )} - </div> - - {/* Expanded details */} - {expanded && ( - <div className="mt-2 space-y-1"> - {riskFactors.map((factor, i) => ( - <div key={i} className="text-[10px]" style={{ color: COLORS.warning, fontFamily: MONO_FONT }}> - {"\u26A0"} {factor.replace(/_/g, " ")} - </div> - ))} - {blockingDiagnostics.map((d, i) => ( - <div key={i} className="text-[10px]" style={{ color: COLORS.danger, fontFamily: MONO_FONT }}> - {"\u2717"} {d.message} - </div> - ))} - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.test.ts b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.test.ts deleted file mode 100644 index f4e6d0ec7..000000000 --- a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { ModelConfig, PhaseCard, PhaseProfile } from "../../../shared/types"; - -vi.mock("@emoji-mart/data", () => ({ - default: { categories: [], emojis: {}, aliases: {}, sheet: { cols: 0, rows: 0 } }, -})); - -vi.mock("@emoji-mart/data/sets/15/native.json", () => ({ - default: { categories: [], emojis: {}, aliases: {}, sheet: { cols: 0, rows: 0 } }, -})); - -import { - applyCodexLowFullAutoPresetToDraft, - applyModelToMissionPhases, - buildCreateMissionDraft, - buildMissionLaunchRequest, - createCustomPhaseFromTemplate, - insertPhaseBeforeCloseout, - phasesMatchProfile, - withLowReasoning, - type CreateDraft, -} from "./CreateMissionDialog"; - -function phase(id: string, phaseKey: string, position: number): PhaseCard { - return { - id, - phaseKey, - name: phaseKey, - description: "", - instructions: "", - model: { provider: "claude", modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; -} - -describe("CreateMissionDialog launch helpers", () => { - it("applies one model config to every phase without mutating the original cards", () => { - const model: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }; - const phases = [phase("phase-1", "planning", 9), phase("phase-2", "development", 10)]; - - const result = applyModelToMissionPhases(phases, model); - - expect(result).toEqual([ - expect.objectContaining({ id: "phase-1", position: 0, model }), - expect.objectContaining({ id: "phase-2", position: 1, model }), - ]); - expect(result[0]).not.toBe(phases[0]); - expect(result[0]!.model).not.toBe(model); - expect(phases[0]!.model).toMatchObject({ modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }); - }); - - it("keeps phase model overrides in the create mission launch request", () => { - const model: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }; - const activePhases = applyModelToMissionPhases([phase("phase-1", "planning", 0), phase("phase-2", "development", 1)], model); - const draft: CreateDraft = { - title: "Smoke mission", - prompt: "Run a throwaway mission smoke test.", - laneId: "", - priority: "normal", - modelConfig: { - orchestratorModel: model, - decisionTimeoutCapHours: 24, - smartBudget: { enabled: false, fiveHourThresholdUsd: 10, weeklyThresholdUsd: 50 }, - }, - phaseProfileId: "builtin:default", - phaseOverride: activePhases, - agentRuntime: { allowParallelAgents: true, allowSubAgents: true, allowClaudeAgentTeams: true }, - permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, - }; - - const request = buildMissionLaunchRequest({ draft, activePhases, defaultLaneId: "lane-primary" }); - - expect(request.laneId).toBe("lane-primary"); - expect(request.modelConfig?.orchestratorModel).toEqual(model); - expect(request.phaseProfileId).toBe("builtin:default"); - expect(request.phaseOverride).toHaveLength(2); - expect(request.phaseOverride?.every((entry) => entry.model.modelId === "openai/gpt-5.5" && entry.model.thinkingLevel === "low")).toBe(true); - expect(request.autostart).toBe(true); - expect(request.executionPolicy?.finalizationPolicyKind).toBe("result_lane"); - }); - - it("applies Smart Budget defaults to new mission launch drafts", () => { - const draft = buildCreateMissionDraft({ - orchestratorModel: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 12, - weeklyThresholdUsd: 80, - fiveHourHardStopPercent: 75, - }, - }); - - const request = buildMissionLaunchRequest({ - draft: { - ...draft, - prompt: "Run a budgeted mission.", - }, - activePhases: [phase("phase-1", "planning", 0)], - defaultLaneId: "lane-primary", - }); - - expect(draft.modelConfig.smartBudget).toEqual({ - enabled: true, - fiveHourThresholdUsd: 12, - weeklyThresholdUsd: 80, - fiveHourHardStopPercent: 75, - }); - expect(request.modelConfig?.smartBudget).toEqual(draft.modelConfig.smartBudget); - }); - - it("sets low reasoning on the currently selected model without changing the selected model id", () => { - const selected: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.4", thinkingLevel: "high" }; - - expect(withLowReasoning(selected)).toEqual({ provider: "codex", modelId: "openai/gpt-5.4", thinkingLevel: "low" }); - expect(selected.thinkingLevel).toBe("high"); - }); - - it("applies the Codex low full-auto preset to orchestrator, phases, and worker permissions", () => { - const draft: CreateDraft = { - title: "Smoke mission", - prompt: "Run a throwaway mission smoke test.", - laneId: "", - priority: "normal", - modelConfig: { - orchestratorModel: { provider: "claude", modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - decisionTimeoutCapHours: 24, - smartBudget: { enabled: false, fiveHourThresholdUsd: 10, weeklyThresholdUsd: 50 }, - }, - phaseProfileId: "builtin:default", - phaseOverride: [phase("phase-1", "planning", 0), phase("phase-2", "development", 1)], - agentRuntime: { allowParallelAgents: true, allowSubAgents: true, allowClaudeAgentTeams: true }, - permissionConfig: { providers: { claude: "full-auto", codex: "default", codexSandbox: "workspace-write" } }, - }; - - const result = applyCodexLowFullAutoPresetToDraft(draft); - - expect(result.modelConfig.orchestratorModel).toMatchObject({ - provider: "codex", - modelId: "openai/gpt-5.5", - thinkingLevel: "low", - }); - expect(result.phaseOverride.every((entry) => - entry.model.provider === "codex" - && entry.model.modelId === "openai/gpt-5.5" - && entry.model.thinkingLevel === "low", - )).toBe(true); - expect(result.permissionConfig.providers).toMatchObject({ - codex: "full-auto", - codexSandbox: "danger-full-access", - }); - expect(draft.permissionConfig.providers?.codex).toBe("default"); - }); - - it("uses the current orchestrator model for newly added custom phases", () => { - const model: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }; - - const result = createCustomPhaseFromTemplate({ - template: "generic", - index: 2, - phaseKey: "custom_3", - at: "2026-01-01T00:00:00.000Z", - model, - }); - - expect(result.model).toEqual(model); - expect(result.model).not.toBe(model); - }); - - it("detects when launch phases have drifted from the selected profile", () => { - const profile: PhaseProfile = { - id: "builtin:default", - name: "Default", - description: "Default phases", - phases: [phase("phase-1", "planning", 0), phase("phase-2", "development", 1)], - isBuiltIn: true, - isDefault: true, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - - expect(phasesMatchProfile(profile, profile.phases)).toBe(true); - expect(phasesMatchProfile(profile, [profile.phases[1]!, profile.phases[0]!])).toBe(false); - expect(phasesMatchProfile(profile, applyModelToMissionPhases(profile.phases, { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }))).toBe(false); - }); - - it("inserts custom phases before closeout and renumbers the phase order", () => { - const phases = [ - phase("phase-1", "planning", 0), - phase("phase-2", "development", 1), - phase("phase-3", "closeout", 2), - ]; - const custom = { ...phase("custom-1", "design_review", 3), isBuiltIn: false, isCustom: true }; - - const result = insertPhaseBeforeCloseout(phases, custom); - - expect(result.map((entry) => entry.phaseKey)).toEqual(["planning", "development", "design_review", "closeout"]); - expect(result.map((entry) => entry.position)).toEqual([0, 1, 2, 3]); - expect(result[2]?.name).toBe("design_review"); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx deleted file mode 100644 index 0a051b853..000000000 --- a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx +++ /dev/null @@ -1,1919 +0,0 @@ -import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { useNavigate } from "react-router-dom"; -import { - Rocket, - X, - Plus, - SpinnerGap, - GitBranch, - ChatCircle, - Robot, - Hash, - Warning, -} from "@phosphor-icons/react"; -import { motion } from "motion/react"; -import type { - AiDetectedAuth, - MissionAgentRuntimeConfig, - MissionModelConfig, - MissionPriority, - ModelConfig, - PhaseCard, - PhaseProfile, - OrchestratorDecisionTimeoutCapHours, - AggregatedUsageStats, - MissionBudgetTelemetrySnapshot, - TeamRuntimeConfig, - MissionPermissionConfig, - CreateMissionArgs, - MissionPreflightResult, - SmartBudgetConfig, -} from "../../../shared/types"; -import { BUILT_IN_PROFILES } from "../../../shared/modelProfiles"; -import { getDefaultModelDescriptor, resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { deriveConfiguredModelIds } from "../../lib/modelOptions"; -import { COLORS, MONO_FONT, SANS_FONT, primaryButton, outlineButton } from "../lanes/laneDesignTokens"; -import { ModelSelector } from "./ModelSelector"; -import { SmartBudgetPanel } from "./SmartBudgetPanel"; -import { MissionPromptInput } from "./MissionPromptInput"; -import { PhaseCardEditor } from "./PhaseCardEditor"; -import { WorkerPermissionsEditor } from "./WorkerPermissionsEditor"; -import { createBuiltInMissionPhaseCards, createBuiltInMissionPhaseProfiles } from "./missionPhaseDefaults"; -import { - getCachedPhaseItems, - getCachedPhaseProfiles, - hasFreshPhaseItems, - hasFreshPhaseProfiles, - setCachedPhaseItems, - setCachedPhaseProfiles, -} from "./missionDialogDataCache"; - -export type CreateDraft = { - title: string; - prompt: string; - laneId: string; - priority: MissionPriority; - modelConfig: MissionModelConfig; - phaseProfileId: string | null; - phaseOverride: PhaseCard[]; - agentRuntime: MissionAgentRuntimeConfig; - teamRuntime?: TeamRuntimeConfig; - permissionConfig: MissionPermissionConfig; -}; - -export type CreateMissionDefaults = { - orchestratorModel?: ModelConfig; - permissionConfig?: MissionPermissionConfig; - smartBudget?: SmartBudgetConfig; -}; - -const DEFAULT_AGENT_RUNTIME: MissionAgentRuntimeConfig = { - allowParallelAgents: true, - allowSubAgents: true, - allowClaudeAgentTeams: true, -}; - -const DECISION_TIMEOUT_CAP_OPTIONS: OrchestratorDecisionTimeoutCapHours[] = [6, 12, 24, 48]; - -const DEFAULT_ORCHESTRATOR_MODEL_BY_PROVIDER: Record<"claude" | "codex", MissionModelConfig["orchestratorModel"]> = { - claude: { provider: "claude", modelId: getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - codex: { provider: "codex", modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5", thinkingLevel: "medium" }, -}; - -const HIGH_TEAMMATE_COUNT_GUARDRAIL_THRESHOLD = 5; -const CREATE_DIALOG_CACHE_TTL_MS = 60_000; -const CREATE_DIALOG_PREWARM_DELAY_MS = 300; -const CREATE_DIALOG_PHASE_SYNC_DELAY_MS = 1_500; - -function cloneSmartBudgetConfig(config: SmartBudgetConfig): SmartBudgetConfig { - return { - ...config, - ...(config.providerLimits ? { providerLimits: { ...config.providerLimits } } : {}), - }; -} - -type CreateMissionDialogAiStatusCache = { - availableModelIds: string[]; - detectedAuth: AiDetectedAuth[] | null; -}; - -const createMissionDialogCache: { - aiStatus: CreateMissionDialogAiStatusCache | null; - aiStatusCachedAt: number; -} = { - aiStatus: null, - aiStatusCachedAt: 0, -}; - -function hasFreshCreateDialogCache(cachedAt: number): boolean { - return cachedAt > 0 && Date.now() - cachedAt < CREATE_DIALOG_CACHE_TTL_MS; -} - -export async function prewarmCreateMissionDialogCache(): Promise<void> { - const shouldRefreshProfiles = !hasFreshPhaseProfiles(); - const shouldRefreshItems = !hasFreshPhaseItems(); - const shouldRefreshAiStatus = !hasFreshCreateDialogCache(createMissionDialogCache.aiStatusCachedAt); - - const tasks: Promise<void>[] = []; - - if (shouldRefreshProfiles) { - tasks.push( - window.ade.missions.listPhaseProfiles({}).then((profiles) => { - if (profiles.length > 0) { - setCachedPhaseProfiles(profiles); - } - }).catch(() => {}) - ); - } - - if (shouldRefreshItems) { - tasks.push( - window.ade.missions.listPhaseItems({}).then((items) => { - setCachedPhaseItems(items); - }).catch(() => {}) - ); - } - - if (shouldRefreshAiStatus) { - tasks.push( - window.ade.ai.getStatus().then((status) => { - const ids = deriveConfiguredModelIds(status, { includeCursor: true, includeDroid: true }); - createMissionDialogCache.aiStatus = { - detectedAuth: status.detectedAuth ?? [], - availableModelIds: ids, - }; - createMissionDialogCache.aiStatusCachedAt = Date.now(); - }).catch(() => {}) - ); - } - - await Promise.all(tasks); -} - -function getDefaultPhaseProfile(profiles: PhaseProfile[]): PhaseProfile | null { - return profiles.find((profile) => profile.isDefault) ?? profiles[0] ?? null; -} - -function cloneProfilePhases(profile: PhaseProfile | null): PhaseCard[] { - if (!profile) return []; - return profile.phases.map((phase, index) => ({ ...phase, position: index })); -} - -function phaseProfileComparable(phase: PhaseCard) { - const constraints = phase.orderingConstraints ?? {}; - return { - phaseKey: phase.phaseKey, - name: phase.name, - description: phase.description, - instructions: phase.instructions, - model: { - provider: phase.model.provider, - modelId: phase.model.modelId, - thinkingLevel: phase.model.thinkingLevel ?? null, - }, - askQuestions: { - enabled: phase.askQuestions.enabled, - maxQuestions: phase.askQuestions.maxQuestions ?? null, - requiredBeforeExit: phase.askQuestions.requiredBeforeExit === true, - }, - validationGate: { - tier: phase.validationGate.tier, - required: phase.validationGate.required, - criteria: phase.validationGate.criteria ?? null, - evidenceRequirements: phase.validationGate.evidenceRequirements ?? [], - capabilityFallback: phase.validationGate.capabilityFallback ?? null, - }, - orderingConstraints: { - mustBeFirst: constraints.mustBeFirst === true, - mustBeLast: constraints.mustBeLast === true, - mustFollow: constraints.mustFollow ?? [], - mustPrecede: constraints.mustPrecede ?? [], - canLoop: constraints.canLoop === true, - loopTarget: constraints.loopTarget ?? null, - }, - requiresApproval: phase.requiresApproval === true, - isBuiltIn: phase.isBuiltIn === true, - isCustom: phase.isCustom === true, - }; -} - -export function phasesMatchProfile(profile: PhaseProfile | null | undefined, phases: PhaseCard[]): boolean { - if (!profile || profile.phases.length !== phases.length) return false; - return profile.phases.every((profilePhase, index) => - JSON.stringify(phaseProfileComparable(profilePhase)) === JSON.stringify(phaseProfileComparable(phases[index]!)) - ); -} - -export function applyModelToMissionPhases(phases: PhaseCard[], model: ModelConfig): PhaseCard[] { - return phases.map((phase, index) => ({ - ...phase, - position: index, - model: { ...model }, - })); -} - -export function withLowReasoning(model: ModelConfig): ModelConfig { - return { ...model, thinkingLevel: "low" }; -} - -export function applyCodexLowFullAutoPresetToDraft( - draft: CreateDraft, - model: ModelConfig = withLowReasoning(DEFAULT_ORCHESTRATOR_MODEL_BY_PROVIDER.codex), -): CreateDraft { - return { - ...draft, - modelConfig: { - ...draft.modelConfig, - profileId: undefined, - orchestratorModel: { ...model }, - }, - phaseOverride: applyModelToMissionPhases(draft.phaseOverride, model), - permissionConfig: { - ...draft.permissionConfig, - providers: { - ...draft.permissionConfig.providers, - codex: "full-auto", - codexSandbox: "danger-full-access", - }, - }, - }; -} - -function buildDefaultModelConfig( - defaults: CreateMissionDefaults | null | undefined, - builtInProfiles: typeof BUILT_IN_PROFILES = BUILT_IN_PROFILES, -): MissionModelConfig { - void builtInProfiles; - - // Prefer explicit orchestratorModel from settings, fall back to claude default - const orchestratorModel = defaults?.orchestratorModel - ?? DEFAULT_ORCHESTRATOR_MODEL_BY_PROVIDER.claude; - - return { - profileId: undefined, - orchestratorModel, - decisionTimeoutCapHours: 24, - intelligenceConfig: undefined, - smartBudget: defaults?.smartBudget - ? cloneSmartBudgetConfig(defaults.smartBudget) - : { enabled: false, fiveHourThresholdUsd: 10, weeklyThresholdUsd: 50 }, - }; -} - -function createDefaultPermissionConfig(defaults: CreateMissionDefaults | null | undefined): MissionPermissionConfig { - if (defaults?.permissionConfig) { - return { - ...defaults.permissionConfig, - ...(defaults.permissionConfig.providers ? { providers: { ...defaults.permissionConfig.providers } } : {}), - }; - } - return { - providers: { - claude: "full-auto", - codex: "default", - droid: "full-auto", - opencode: "full-auto", - codexSandbox: "workspace-write", - }, - }; -} - -export function resolveLaunchLaneId(args: { draftLaneId: string; defaultLaneId?: string | null }): string { - const explicit = args.draftLaneId.trim(); - if (explicit.length > 0) return explicit; - const fallback = String(args.defaultLaneId ?? "").trim(); - return fallback; -} - -export function buildCreateMissionDraft( - defaults: CreateMissionDefaults | null | undefined, - builtInProfiles: typeof BUILT_IN_PROFILES = BUILT_IN_PROFILES, -): CreateDraft { - return { - title: "", - prompt: "", - laneId: "", - priority: "normal", - modelConfig: buildDefaultModelConfig(defaults, builtInProfiles), - phaseProfileId: null, - phaseOverride: [], - agentRuntime: { ...DEFAULT_AGENT_RUNTIME }, - permissionConfig: createDefaultPermissionConfig(defaults), - }; -} - -export function buildMissionLaunchRequest(args: { - draft: CreateDraft; - activePhases: PhaseCard[]; - defaultLaneId?: string | null; -}): CreateMissionArgs { - const resolvedLaneId = resolveLaunchLaneId({ - draftLaneId: args.draft.laneId, - defaultLaneId: args.defaultLaneId, - }); - return { - title: args.draft.title.trim() || undefined, - prompt: args.draft.prompt.trim(), - laneId: resolvedLaneId || undefined, - priority: args.draft.priority, - agentRuntime: args.draft.agentRuntime, - teamRuntime: args.draft.teamRuntime, - executionPolicy: { - finalizationPolicyKind: "result_lane", - ...(args.draft.teamRuntime ? { teamRuntime: args.draft.teamRuntime } : {}), - }, - modelConfig: { - ...args.draft.modelConfig, - decisionTimeoutCapHours: args.draft.modelConfig.decisionTimeoutCapHours ?? 24, - }, - phaseProfileId: args.draft.phaseProfileId, - phaseOverride: args.activePhases, - permissionConfig: args.draft.permissionConfig, - autostart: true, - launchMode: "autopilot", - }; -} - -function validatePhaseOrder(cards: PhaseCard[]): string[] { - if (!cards.length) return ["At least one phase is required."]; - const errors: string[] = []; - const byKey = new Map<string, number>(); - const isDevelopmentLikePhaseKey = (phaseKey: string): boolean => { - const normalized = phaseKey.trim().toLowerCase(); - return ( - normalized === "development" - || normalized === "implementation" - || normalized === "build" - || normalized === "code" - || normalized.startsWith("development_") - || normalized.startsWith("implementation_") - || normalized.endsWith("_development") - || normalized.endsWith("_implementation") - ); - }; - const labelForKey = (phaseKey: string): string => { - const card = cards.find((entry) => entry.phaseKey.trim().toLowerCase() === phaseKey.trim().toLowerCase()); - return card?.name?.trim() || phaseKey; - }; - let firstDevelopmentLikeIndex = -1; - let firstPlanningIndex = -1; - cards.forEach((card, index) => { - const phaseKey = card.phaseKey.trim().toLowerCase(); - if (!phaseKey) errors.push(`Phase ${index + 1} is missing a key.`); - if (byKey.has(phaseKey)) errors.push(`Duplicate phase key: ${card.phaseKey}`); - byKey.set(phaseKey, index); - if (isDevelopmentLikePhaseKey(phaseKey) && firstDevelopmentLikeIndex < 0) firstDevelopmentLikeIndex = index; - if (phaseKey === "planning" && firstPlanningIndex < 0) firstPlanningIndex = index; - }); - cards.forEach((card, index) => { - const phaseKey = card.phaseKey.trim().toLowerCase(); - if (!phaseKey) return; - const label = card.name.trim() || card.phaseKey; - const orderingConstraints = card.orderingConstraints ?? {}; - if (orderingConstraints.mustBeFirst && index !== 0) { - errors.push(`${label} phase must be first.`); - } - if (orderingConstraints.mustBeLast && index !== cards.length - 1) { - errors.push(`${label} phase must be last.`); - } - for (const requiredPredecessor of orderingConstraints.mustFollow ?? []) { - const predecessorKey = requiredPredecessor.trim().toLowerCase(); - const predecessorIndex = byKey.get(predecessorKey); - if (predecessorIndex == null) { - errors.push(`${label} phase requires ${labelForKey(predecessorKey)} phase.`); - } else if (predecessorIndex >= index) { - errors.push(`${label} phase must come after ${labelForKey(predecessorKey)}.`); - } - } - for (const requiredSuccessor of orderingConstraints.mustPrecede ?? []) { - const successorKey = requiredSuccessor.trim().toLowerCase(); - const successorIndex = byKey.get(successorKey); - if (successorIndex == null) { - errors.push(`${label} phase requires ${labelForKey(successorKey)} phase.`); - } else if (successorIndex <= index) { - errors.push(`${label} phase must come before ${labelForKey(successorKey)}.`); - } - } - }); - if (!byKey.has("planning")) errors.push("Planning phase is required."); - if (firstDevelopmentLikeIndex < 0) errors.push("A development or custom implementation phase is required."); - if (firstPlanningIndex >= 0 && firstDevelopmentLikeIndex >= 0 && firstPlanningIndex > firstDevelopmentLikeIndex) { - errors.push("Planning phase must appear before implementation."); - } - return [...new Set(errors)]; -} - -const DLG_INPUT_STYLE: React.CSSProperties = { background: COLORS.recessedBg, border: `1px solid ${COLORS.outlineBorder}`, color: COLORS.textPrimary, fontFamily: MONO_FONT, borderRadius: 0 }; -const DLG_LABEL_STYLE: React.CSSProperties = { fontSize: 10, fontWeight: 700, fontFamily: MONO_FONT, textTransform: "uppercase" as const, letterSpacing: "1px", color: COLORS.textMuted }; - -type CustomPhaseTemplate = "generic" | "slash-command" | "review"; - -export function createCustomPhaseFromTemplate(args: { - template: CustomPhaseTemplate; - index: number; - phaseKey: string; - at: string; - model: ModelConfig; -}): PhaseCard { - const base: Pick<PhaseCard, "name" | "description" | "instructions" | "askQuestions" | "validationGate"> = (() => { - if (args.template === "slash-command") { - return { - name: "Slash Command", - description: "Run a configured slash command with the selected agent.", - instructions: - "Run the slash command configured for this phase, capture the result, and summarize any follow-up work before the mission advances. Replace this text with the exact command, target agent, and done criteria.", - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "The slash command result is captured and any requested follow-up is dispositioned.", - evidenceRequirements: ["final_outcome_summary"], - capabilityFallback: "warn", - }, - }; - } - if (args.template === "review") { - return { - name: "Review", - description: "Review the current mission output before closeout.", - instructions: - "Review the result lane against the mission goal, call out regressions or missing proof, and request fixes before closeout if needed.", - askQuestions: { enabled: false }, - validationGate: { - tier: "dedicated", - required: true, - criteria: "The reviewer confirms the result matches the mission goal or records blocking gaps.", - evidenceRequirements: ["review_summary", "risk_notes"], - capabilityFallback: "warn", - }, - }; - } - return { - name: `Custom Phase ${args.index + 1}`, - description: "", - instructions: "", - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - }; - })(); - - return { - id: `custom:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, - phaseKey: args.phaseKey, - name: base.name, - description: base.description, - instructions: base.instructions, - model: { ...args.model }, - budget: {}, - orderingConstraints: {}, - askQuestions: base.askQuestions, - validationGate: base.validationGate, - isBuiltIn: false, - isCustom: true, - position: args.index, - createdAt: args.at, - updatedAt: args.at, - }; -} - -function isCloseoutPhase(phase: PhaseCard): boolean { - return phase.phaseKey.trim().toLowerCase() === "closeout"; -} - -function phaseInsertionIndexBeforeCloseout(phases: PhaseCard[]): number { - const closeoutIndex = phases.findIndex(isCloseoutPhase); - return closeoutIndex >= 0 ? closeoutIndex : phases.length; -} - -export function insertPhaseBeforeCloseout(phases: PhaseCard[], phase: PhaseCard): PhaseCard[] { - const insertionIndex = phaseInsertionIndexBeforeCloseout(phases); - return [ - ...phases.slice(0, insertionIndex), - phase, - ...phases.slice(insertionIndex), - ].map((entry, index) => ( - entry.position === index - ? entry - : { ...entry, position: index } - )); -} - -// --------------------------------------------------------------------------- -// Worker Permissions — thin wrapper around extracted WorkerPermissionsEditor -// --------------------------------------------------------------------------- - -function WorkerPermissionsSection({ - draft, - activePhases, - setDraft, - dlgLabelStyle, - dlgInputStyle, -}: { - draft: CreateDraft; - activePhases: PhaseCard[]; - setDraft: React.Dispatch<React.SetStateAction<CreateDraft>>; - dlgLabelStyle: React.CSSProperties; - dlgInputStyle: React.CSSProperties; -}) { - return ( - <WorkerPermissionsEditor - orchestratorModelId={draft.modelConfig.orchestratorModel?.modelId} - phases={activePhases} - permissionConfig={draft.permissionConfig} - onPermissionChange={(next) => setDraft((p) => ({ ...p, permissionConfig: next }))} - labelStyle={dlgLabelStyle} - inputStyle={dlgInputStyle} - /> - ); -} - -function CreateMissionDialogInner({ - open, - onClose, - onLaunch, - busy, - lanes, - defaultLaneId, - missionDefaults, - resetVersion, -}: { - open: boolean; - onClose: () => void; - onLaunch: (draft: CreateDraft) => void; - busy: boolean; - lanes: Array<{ id: string; name: string }>; - defaultLaneId?: string | null; - missionDefaults?: CreateMissionDefaults | null; - resetVersion?: number; -}) { - const navigate = useNavigate(); - const openAiProvidersSettings = useCallback(() => { - navigate("/settings?tab=ai#ai-providers"); - }, [navigate]); - const sortedLanes = useMemo( - () => [...lanes].sort((a, b) => a.name.localeCompare(b.name)), - [lanes] - ); - const initialDraft = useMemo(() => buildCreateMissionDraft(missionDefaults), [missionDefaults]); - const builtInPhaseCards = useMemo(() => createBuiltInMissionPhaseCards(), []); - const builtInPhaseProfiles = useMemo( - () => createBuiltInMissionPhaseProfiles(builtInPhaseCards), - [builtInPhaseCards] - ); - const [hasMountedBody, setHasMountedBody] = useState(open); - const [attachments, setAttachments] = useState<string[]>([]); - const [phaseProfiles, setPhaseProfiles] = useState<PhaseProfile[]>(() => { - const cachedProfiles = getCachedPhaseProfiles(); - return cachedProfiles?.length ? cachedProfiles : builtInPhaseProfiles; - }); - const [phaseLoading, setPhaseLoading] = useState(false); - const [phaseError, setPhaseError] = useState<string | null>(null); - const [expandedPhases, setExpandedPhases] = useState<Record<string, boolean>>({}); - const [disabledPhases, setDisabledPhases] = useState<Record<string, boolean>>({}); - const [availableModelIds, setAvailableModelIds] = useState<string[] | undefined>(() => createMissionDialogCache.aiStatus?.availableModelIds); - const [aiDetectedAuth, setAiDetectedAuth] = useState<AiDetectedAuth[] | null>(() => createMissionDialogCache.aiStatus?.detectedAuth ?? null); - const [currentUsage, setCurrentUsage] = useState<AggregatedUsageStats | null>(null); - const [weeklyUsage, setWeeklyUsage] = useState<AggregatedUsageStats | null>(null); - const [budgetTelemetry, setBudgetTelemetry] = useState<MissionBudgetTelemetrySnapshot | null>(null); - const [phaseItems, setPhaseItems] = useState<PhaseCard[]>(() => getCachedPhaseItems() ?? []); - const [phaseItemsLoading, setPhaseItemsLoading] = useState(false); - const [phaseItemsError, setPhaseItemsError] = useState<string | null>(null); - const [selectedPhaseItemKey, setSelectedPhaseItemKey] = useState<string>(""); - const [phaseNotice, setPhaseNotice] = useState<string | null>(null); - const [teamBudgetGuardrailConfirmed, setTeamBudgetGuardrailConfirmed] = useState(false); - const [preflightLoading, setPreflightLoading] = useState(false); - const [preflightError, setPreflightError] = useState<string | null>(null); - const [preflightResult, setPreflightResult] = useState<MissionPreflightResult | null>(null); - const [preflightFingerprint, setPreflightFingerprint] = useState<string | null>(null); - const [draft, setDraft] = useState<CreateDraft>(initialDraft); - const [nonCriticalReady, setNonCriticalReady] = useState(false); - const initializedResetVersionRef = useRef<number | undefined>(undefined); - const phaseDataSyncStartedRef = useRef(false); - - useEffect(() => { - if (hasMountedBody) return; - if (open) { - setHasMountedBody(true); - return; - } - const timeoutId = window.setTimeout(() => { - setHasMountedBody(true); - }, CREATE_DIALOG_PREWARM_DELAY_MS); - return () => { - window.clearTimeout(timeoutId); - }; - }, [hasMountedBody, open]); - - useEffect(() => { - if (!open) return; - if (initializedResetVersionRef.current === resetVersion) return; - initializedResetVersionRef.current = resetVersion; - const cachedProfiles = getCachedPhaseProfiles(); - const fallbackProfiles = cachedProfiles?.length ? cachedProfiles : builtInPhaseProfiles; - const cachedItems = getCachedPhaseItems() ?? []; - const resetDraft = buildCreateMissionDraft(missionDefaults); - const defaultCachedProfile = getDefaultPhaseProfile(fallbackProfiles); - if (defaultCachedProfile) { - resetDraft.phaseProfileId = defaultCachedProfile.id; - resetDraft.phaseOverride = cloneProfilePhases(defaultCachedProfile); - } - setAttachments([]); - setPhaseError(null); - setPhaseNotice(null); - setPhaseItemsError(null); - setSelectedPhaseItemKey(""); - setExpandedPhases({}); - setDisabledPhases({}); - setTeamBudgetGuardrailConfirmed(false); - setDraft(resetDraft); - setPhaseProfiles(fallbackProfiles); - setPhaseItems(cachedItems); - setAvailableModelIds( - hasFreshCreateDialogCache(createMissionDialogCache.aiStatusCachedAt) - ? createMissionDialogCache.aiStatus?.availableModelIds - : undefined - ); - setAiDetectedAuth( - hasFreshCreateDialogCache(createMissionDialogCache.aiStatusCachedAt) - ? createMissionDialogCache.aiStatus?.detectedAuth ?? null - : null - ); - setPhaseLoading(false); - setPhaseItemsLoading(false); - }, [open, missionDefaults, resetVersion, builtInPhaseProfiles]); - - useEffect(() => { - if (!open) { - setNonCriticalReady(false); - return; - } - let cancelled = false; - const timeoutId = window.setTimeout(() => { - if (!cancelled) { - setNonCriticalReady(true); - } - }, 1200); - return () => { - cancelled = true; - window.clearTimeout(timeoutId); - }; - }, [open]); - - useEffect(() => { - if (!hasMountedBody || phaseDataSyncStartedRef.current) return; - const shouldRefreshProfiles = !hasFreshPhaseProfiles(); - const shouldRefreshItems = !hasFreshPhaseItems(); - if (!shouldRefreshProfiles && !shouldRefreshItems) return; - - phaseDataSyncStartedRef.current = true; - let cancelled = false; - const timeoutId = window.setTimeout(() => { - if (cancelled) return; - if (shouldRefreshProfiles) setPhaseLoading(true); - if (shouldRefreshItems) setPhaseItemsLoading(true); - - if (shouldRefreshProfiles) { - void window.ade.missions - .listPhaseProfiles({}) - .then((profiles) => { - if (cancelled) return; - const nextProfiles = profiles.length > 0 ? profiles : builtInPhaseProfiles; - setCachedPhaseProfiles(nextProfiles); - setPhaseProfiles(nextProfiles); - const defaultProfile = getDefaultPhaseProfile(nextProfiles); - setDraft((prev) => { - if (prev.phaseOverride.length > 0) return prev; - return { - ...prev, - phaseProfileId: defaultProfile?.id ?? null, - phaseOverride: cloneProfilePhases(defaultProfile), - }; - }); - }) - .catch((err) => { - if (!cancelled) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } - }) - .finally(() => { - if (!cancelled) setPhaseLoading(false); - }); - } - - if (shouldRefreshItems) { - void window.ade.missions - .listPhaseItems({}) - .then((items) => { - if (cancelled) return; - setCachedPhaseItems(items); - setPhaseItems(items); - }) - .catch((err) => { - if (!cancelled) { - setPhaseItemsError(err instanceof Error ? err.message : String(err)); - } - }) - .finally(() => { - if (!cancelled) setPhaseItemsLoading(false); - }); - } - }, CREATE_DIALOG_PHASE_SYNC_DELAY_MS); - - return () => { - cancelled = true; - window.clearTimeout(timeoutId); - }; - }, [hasMountedBody, builtInPhaseProfiles]); - - useEffect(() => { - if (!open || !nonCriticalReady || hasFreshCreateDialogCache(createMissionDialogCache.aiStatusCachedAt)) return; - let cancelled = false; - - const rafId = requestAnimationFrame(() => { - if (cancelled) return; - void window.ade.ai.getStatus().then((status) => { - if (cancelled) return; - const ids = deriveConfiguredModelIds(status, { includeCursor: true, includeDroid: true }); - const cachedStatus: CreateMissionDialogAiStatusCache = { - detectedAuth: status.detectedAuth ?? [], - availableModelIds: ids, - }; - createMissionDialogCache.aiStatus = cachedStatus; - createMissionDialogCache.aiStatusCachedAt = Date.now(); - setAiDetectedAuth(cachedStatus.detectedAuth); - setAvailableModelIds(cachedStatus.availableModelIds); - }).catch(() => { - if (!cancelled) setAvailableModelIds(undefined); - }); - }); - - return () => { - cancelled = true; - cancelAnimationFrame(rafId); - }; - }, [open, nonCriticalReady]); - - const activePhases = useMemo(() => { - const enabled = draft.phaseOverride - .filter((phase) => !disabledPhases[phase.id]) - .map((phase, index) => ({ ...phase, position: index })); - const activeKeys = new Set( - enabled.map((phase) => phase.phaseKey.trim().toLowerCase()).filter((key) => key.length > 0) - ); - return enabled.map((phase, index) => { - const constraints = phase.orderingConstraints ?? {}; - return { - ...phase, - position: index, - orderingConstraints: { - ...constraints, - mustFollow: Array.isArray(constraints.mustFollow) - ? constraints.mustFollow.filter((entry) => activeKeys.has(String(entry ?? "").trim().toLowerCase())) - : [], - mustPrecede: Array.isArray(constraints.mustPrecede) - ? constraints.mustPrecede.filter((entry) => activeKeys.has(String(entry ?? "").trim().toLowerCase())) - : [], - }, - }; - }); - }, [draft.phaseOverride, disabledPhases]); - - const selectedBudgetFamilies = useMemo(() => { - const subscriptionProviders = new Set<"claude" | "codex" | "cursor" | "droid">(); - let hasApiModels = false; - const inspectModel = (rawModelId: string | null | undefined): void => { - const modelId = String(rawModelId ?? "").trim(); - if (!modelId.length) return; - const descriptor = resolveModelDescriptor(modelId); - if (!descriptor) { - hasApiModels = true; - return; - } - if (descriptor.isCliWrapped && descriptor.family === "anthropic") { - subscriptionProviders.add("claude"); - return; - } - if (descriptor.isCliWrapped && descriptor.family === "openai") { - subscriptionProviders.add("codex"); - return; - } - if (descriptor.isCliWrapped && descriptor.family === "cursor") { - subscriptionProviders.add("cursor"); - return; - } - if (descriptor.isCliWrapped && descriptor.family === "factory") { - subscriptionProviders.add("droid"); - return; - } - if (descriptor.authTypes.includes("api-key") || descriptor.authTypes.includes("openrouter")) { - hasApiModels = true; - } - }; - for (const phase of activePhases) inspectModel(phase.model.modelId); - inspectModel(draft.modelConfig.orchestratorModel?.modelId); - return { - subscriptionProviders: [...subscriptionProviders].sort(), - hasApiModels, - }; - }, [activePhases, draft.modelConfig.orchestratorModel?.modelId]); - - useEffect(() => { - const smartBudgetEnabled = draft.modelConfig.smartBudget?.enabled === true; - if (!open || !nonCriticalReady || !smartBudgetEnabled) { - setBudgetTelemetry(null); - setCurrentUsage(null); - setWeeklyUsage(null); - return; - } - let cancelled = false; - if (selectedBudgetFamilies.subscriptionProviders.length > 0) { - void window.ade.orchestrator.getMissionBudgetTelemetry({ - providers: selectedBudgetFamilies.subscriptionProviders, - }).then((snapshot) => { - if (!cancelled) setBudgetTelemetry(snapshot); - }).catch(() => { - if (!cancelled) setBudgetTelemetry(null); - }); - } else { - setBudgetTelemetry(null); - } - if (!selectedBudgetFamilies.hasApiModels) { - setCurrentUsage(null); - setWeeklyUsage(null); - return () => { - cancelled = true; - }; - } - const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(); - const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - void window.ade.orchestrator.getAggregatedUsage({ since: fiveHoursAgo }).then((stats) => { - if (!cancelled) setCurrentUsage(stats); - }).catch(() => { - if (!cancelled) setCurrentUsage(null); - }); - void window.ade.orchestrator.getAggregatedUsage({ since: oneWeekAgo }).then((stats) => { - if (!cancelled) setWeeklyUsage(stats); - }).catch(() => { - if (!cancelled) setWeeklyUsage(null); - }); - return () => { - cancelled = true; - }; - }, [ - draft.modelConfig.smartBudget?.enabled, - open, - nonCriticalReady, - selectedBudgetFamilies.hasApiModels, - selectedBudgetFamilies.subscriptionProviders, - ]); - - const launcherPerProviderUsage = useMemo(() => { - if (!budgetTelemetry?.perProvider?.length) return undefined; - const providerLimits = draft.modelConfig.smartBudget?.providerLimits ?? {}; - return budgetTelemetry.perProvider.map((providerSnapshot) => { - const limits = providerLimits[providerSnapshot.provider]; - const fiveHourLimit = typeof limits?.fiveHourTokenLimit === "number" - ? limits.fiveHourTokenLimit - : null; - const weeklyLimit = typeof limits?.weeklyTokenLimit === "number" - ? limits.weeklyTokenLimit - : null; - const fiveHourPct = fiveHourLimit != null && fiveHourLimit > 0 - ? Number(((providerSnapshot.fiveHour.usedTokens / fiveHourLimit) * 100).toFixed(1)) - : null; - const weeklyPct = weeklyLimit != null && weeklyLimit > 0 - ? Number(((providerSnapshot.weekly.usedTokens / weeklyLimit) * 100).toFixed(1)) - : null; - return { - provider: providerSnapshot.provider, - fiveHour: { - ...providerSnapshot.fiveHour, - limitTokens: fiveHourLimit, - usedPct: fiveHourPct, - }, - weekly: { - ...providerSnapshot.weekly, - limitTokens: weeklyLimit, - usedPct: weeklyPct, - }, - }; - }); - }, [budgetTelemetry, draft.modelConfig.smartBudget?.providerLimits]); - - const phaseValidationErrors = useMemo(() => validatePhaseOrder(activePhases), [activePhases]); - const customPhaseItems = useMemo( - () => phaseItems.filter((item) => !item.isBuiltIn), - [phaseItems] - ); - const selectedPhaseProfile = useMemo( - () => phaseProfiles.find((profile) => profile.id === draft.phaseProfileId) ?? null, - [draft.phaseProfileId, phaseProfiles] - ); - const phaseProfileModified = useMemo( - () => Boolean(selectedPhaseProfile && !phasesMatchProfile(selectedPhaseProfile, draft.phaseOverride)), - [draft.phaseOverride, selectedPhaseProfile] - ); - const phaseProfileSelectValue = phaseProfileModified ? "__modified__" : draft.phaseProfileId ?? ""; - - const appendPhaseFromItem = useCallback((item: PhaseCard) => { - setDraft((prev) => { - const usedKeys = new Set( - prev.phaseOverride.map((phase) => phase.phaseKey.trim().toLowerCase()).filter((key) => key.length > 0) - ); - const insertionIndex = phaseInsertionIndexBeforeCloseout(prev.phaseOverride); - const baseKey = item.phaseKey.trim().length > 0 - ? item.phaseKey.trim() - : `custom_${insertionIndex + 1}`; - let phaseKey = baseKey; - let suffix = 2; - while (usedKeys.has(phaseKey.toLowerCase())) { - phaseKey = `${baseKey}_${suffix}`; - suffix += 1; - } - const now = new Date().toISOString(); - const nextPhase: PhaseCard = { - ...item, - id: `custom:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, - phaseKey, - isBuiltIn: false, - isCustom: true, - position: insertionIndex, - createdAt: now, - updatedAt: now, - }; - return { - ...prev, - phaseOverride: insertPhaseBeforeCloseout(prev.phaseOverride, nextPhase), - }; - }); - }, []); - - const appendCustomPhase = useCallback((template: CustomPhaseTemplate) => { - setDraft((prev) => { - const usedKeys = new Set( - prev.phaseOverride.map((phase) => phase.phaseKey.trim().toLowerCase()).filter((key) => key.length > 0) - ); - const insertionIndex = phaseInsertionIndexBeforeCloseout(prev.phaseOverride); - const baseKey = template === "slash-command" - ? "slash_command" - : template === "review" - ? "review" - : `custom_${insertionIndex + 1}`; - let phaseKey = baseKey; - let suffix = 2; - while (usedKeys.has(phaseKey.toLowerCase())) { - phaseKey = `${baseKey}_${suffix}`; - suffix += 1; - } - const now = new Date().toISOString(); - const nextPhase = createCustomPhaseFromTemplate({ - template, - index: insertionIndex, - phaseKey, - at: now, - model: prev.modelConfig.orchestratorModel, - }); - return { - ...prev, - phaseOverride: insertPhaseBeforeCloseout(prev.phaseOverride, nextPhase), - }; - }); - }, []); - - const billingContext = useMemo(() => { - if (!aiDetectedAuth?.length) return undefined; - const subProviders: string[] = []; - const apiProviders: string[] = []; - for (const auth of aiDetectedAuth) { - if (!auth.authenticated) continue; - if (auth.type === "cli-subscription" && auth.cli) { - const familyMap: Record<string, string> = { claude: "anthropic", codex: "openai", cursor: "cursor", droid: "factory" }; - if (familyMap[auth.cli]) subProviders.push(familyMap[auth.cli]!); - } - if (auth.type === "api-key" && auth.provider) { - apiProviders.push(auth.provider); - } - } - return { - hasSubscription: subProviders.length > 0, - subscriptionProviders: [...new Set(subProviders)], - apiProviders: [...new Set(apiProviders)], - }; - }, [aiDetectedAuth]); - - const teamBudgetGuardrailTeammateCount = draft.teamRuntime?.enabled - ? Math.max(1, draft.teamRuntime.teammateCount ?? 2) - : 0; - const showAdvancedLaunchControls = nonCriticalReady; - const teamBudgetGuardrailActive = teamBudgetGuardrailTeammateCount >= HIGH_TEAMMATE_COUNT_GUARDRAIL_THRESHOLD - && draft.modelConfig.smartBudget?.enabled !== true; - const teamBudgetGuardrailEnabled = draft.teamRuntime?.enabled === true; - const teamBudgetGuardrailSmartBudget = draft.modelConfig.smartBudget?.enabled === true; - const launchRequest = useMemo( - () => buildMissionLaunchRequest({ draft, activePhases, defaultLaneId }), - [activePhases, defaultLaneId, draft], - ); - const launchFingerprint = useMemo(() => JSON.stringify(launchRequest), [launchRequest]); - const preflightCurrent = preflightResult && preflightFingerprint === launchFingerprint ? preflightResult : null; - const checklistFailures = preflightCurrent?.checklist.filter((item) => item.severity === "fail") ?? []; - const checklistWarnings = preflightCurrent?.checklist.filter((item) => item.severity === "warning") ?? []; - const launchBlockedReason = busy - ? "Mission launch is already running." - : preflightLoading - ? "Checking lane, model, phase, and permission readiness..." - : !draft.prompt.trim() - ? "Add a mission prompt to continue." - : phaseValidationErrors.length > 0 - ? phaseValidationErrors[0] ?? "Fix the phase configuration to continue." - : preflightCurrent && !preflightCurrent.canLaunch - ? checklistFailures[0]?.summary ?? "Fix the launch blockers above to continue." - : null; - const launchFooterMessage = launchBlockedReason - ?? (preflightCurrent?.canLaunch - ? "Preflight passed. Launch will keep work isolated in the mission result lane." - : preflightError - ? "Preflight did not complete. Review launch will try the checks again." - : "Review launch checks the lane, model, phase order, and permissions before starting."); - const launchDisabled = Boolean(launchBlockedReason); - - const applyModelEverywhere = useCallback((model: ModelConfig, updateOrchestrator: boolean) => { - setDraft((prev) => ({ - ...prev, - modelConfig: updateOrchestrator - ? { ...prev.modelConfig, profileId: undefined, orchestratorModel: { ...model } } - : prev.modelConfig, - phaseOverride: applyModelToMissionPhases(prev.phaseOverride, model), - })); - }, []); - - useEffect(() => { - setTeamBudgetGuardrailConfirmed(false); - }, [teamBudgetGuardrailEnabled, teamBudgetGuardrailTeammateCount, teamBudgetGuardrailSmartBudget]); - - useEffect(() => { - setPreflightError(null); - setPreflightResult(null); - setPreflightFingerprint(null); - }, [launchFingerprint]); - - const handleLaunch = useCallback(async () => { - if (!draft.prompt.trim()) return; - if (validatePhaseOrder(activePhases).length > 0) return; - if (teamBudgetGuardrailActive && !teamBudgetGuardrailConfirmed) { - const confirmed = window.confirm( - `Team runtime is configured with ${teamBudgetGuardrailTeammateCount} teammates while Smart Budget is disabled. This can increase token/cost burn quickly. Continue?` - ); - if (!confirmed) return; - setTeamBudgetGuardrailConfirmed(true); - } - if (!preflightCurrent) { - try { - setPreflightLoading(true); - const result = await window.ade.missions.preflight({ launch: launchRequest }); - setPreflightResult(result); - setPreflightFingerprint(launchFingerprint); - setPreflightError(null); - } catch (error) { - setPreflightError(error instanceof Error ? error.message : String(error)); - } finally { - setPreflightLoading(false); - } - return; - } - if (!preflightCurrent.canLaunch) return; - onLaunch({ ...draft, laneId: launchRequest.laneId ?? "", phaseOverride: activePhases }); - }, [ - activePhases, - draft, - launchFingerprint, - launchRequest, - onLaunch, - preflightCurrent, - teamBudgetGuardrailActive, - teamBudgetGuardrailConfirmed, - teamBudgetGuardrailTeammateCount, - ]); - - const shouldRenderBody = hasMountedBody || open; - if (!shouldRenderBody) return null; - - const dlgInputStyle = DLG_INPUT_STYLE; - const dlgLabelStyle = DLG_LABEL_STYLE; - - return ( - <div - aria-hidden={!open} - className={`fixed inset-0 z-50 flex items-center justify-center bg-black/70 transition-opacity duration-150 ${ - open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" - }`} - style={{ visibility: open ? "visible" : "hidden" }} - onClick={() => { - if (busy) return; - onClose(); - }} - > - <motion.div - initial={false} - animate={{ - opacity: open ? 1 : 0, - scale: open ? 1 : 0.98, - transition: { duration: open ? 0.15 : 0.1 }, - }} - className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden shadow-2xl" - style={{ - background: COLORS.cardBgSolid, - border: `1px solid ${COLORS.outlineBorder}`, - boxShadow: "0 28px 80px -36px rgba(0,0,0,0.82)", - }} - onClick={(event) => event.stopPropagation()} - > - <div - className="flex items-center justify-between px-5 h-14" - style={{ - background: "#120F1A", - borderBottom: `1px solid ${COLORS.border}`, - }} - > - <div className="flex items-center gap-2"> - <Rocket className="h-4 w-4" style={{ color: COLORS.accent }} /> - <h2 className="text-sm font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}>NEW MISSION</h2> - </div> - <button onClick={onClose} className="transition-colors" style={{ color: COLORS.textMuted }}> - <X className="h-4 w-4" /> - </button> - </div> - - <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4"> - {/* 1. Mission Prompt */} - <div className="space-y-1"> - <span style={dlgLabelStyle}> - <ChatCircle size={12} weight="bold" className="inline mr-1 -mt-0.5" style={{ color: COLORS.textMuted }} /> - MISSION PROMPT * - </span> - <MissionPromptInput - value={draft.prompt} - onChange={(v) => setDraft((p) => ({ ...p, prompt: v }))} - attachments={attachments} - onAttachmentsChange={setAttachments} - /> - </div> - - {/* 2. Title */} - <label className="block space-y-1"> - <span style={dlgLabelStyle}>TITLE (OPTIONAL)</span> - <input - value={draft.title} - onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} - placeholder="e.g. Refactor auth middleware" - className="h-8 w-full px-3 text-xs outline-none" - style={dlgInputStyle} - /> - </label> - - {/* 3. Base Lane */} - <label className="block space-y-1"> - <span style={dlgLabelStyle}> - <GitBranch size={12} weight="bold" className="inline mr-1 -mt-0.5" style={{ color: COLORS.textMuted }} /> - BASE LANE - </span> - <select - value={draft.laneId} - onChange={(e) => setDraft((p) => ({ ...p, laneId: e.target.value }))} - className="h-8 w-full px-3 text-xs outline-none" - style={dlgInputStyle} - > - <option value="">Primary lane (auto)</option> - {sortedLanes.map((lane) => ( - <option key={lane.id} value={lane.id}>{lane.name}</option> - ))} - </select> - </label> - - <div style={{ borderTop: `1px solid ${COLORS.border}`, margin: "4px 0" }} /> - - {/* 4. Orchestrator Model */} - <div className="space-y-1"> - <span style={dlgLabelStyle}> - <Robot size={12} weight="bold" className="inline mr-1 -mt-0.5" style={{ color: COLORS.textMuted }} /> - ORCHESTRATOR MODEL - </span> - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - <div style={{ flex: 1 }}> - <ModelSelector - value={draft.modelConfig.orchestratorModel} - onChange={(config) => { - setDraft((p) => ({ - ...p, - modelConfig: { ...p.modelConfig, profileId: undefined, orchestratorModel: config }, - })); - }} - showRecommendedBadge - availableModelIds={availableModelIds} - onOpenAiSettings={openAiProvidersSettings} - /> - </div> - {aiDetectedAuth && ( - <div style={{ display: "flex", gap: 4, alignItems: "center", flexShrink: 0 }}> - {(() => { - const providers = new Map<string, boolean>(); - for (const a of aiDetectedAuth) { - const label = a.cli ?? a.provider ?? a.type; - if (label && !providers.has(label)) { - providers.set(label, !!a.authenticated); - } - } - return Array.from(providers.entries()).map(([label, authed]) => ( - <span - key={label} - title={`${label}: ${authed ? "authenticated" : "not detected"}`} - style={{ - display: "inline-flex", - alignItems: "center", - gap: 3, - fontSize: 9, - fontFamily: MONO_FONT, - color: authed ? COLORS.success : COLORS.danger, - }} - > - <span style={{ - width: 6, - height: 6, - borderRadius: 3, - background: authed ? COLORS.success : COLORS.danger, - display: "inline-block", - }} /> - {label} - </span> - )); - })()} - </div> - )} - </div> - <div className="flex flex-wrap items-center gap-2"> - <button - type="button" - style={outlineButton()} - disabled={draft.phaseOverride.length === 0} - onClick={() => applyModelEverywhere(draft.modelConfig.orchestratorModel, false)} - title="Copy the selected orchestrator model and reasoning level to every phase." - > - APPLY TO PHASES - </button> - <button - type="button" - style={outlineButton()} - disabled={draft.phaseOverride.length === 0} - onClick={() => applyModelEverywhere(withLowReasoning(draft.modelConfig.orchestratorModel), true)} - title="Set the selected orchestrator model to low reasoning and copy it to every phase." - > - LOW + APPLY TO PHASES - </button> - <button - type="button" - style={outlineButton()} - disabled={draft.phaseOverride.length === 0} - onClick={() => setDraft((prev) => applyCodexLowFullAutoPresetToDraft(prev))} - title="Set Codex GPT-5.5 low reasoning for the orchestrator and every phase, and set Codex workers to full-auto." - > - CODEX LOW + FULL-AUTO - </button> - </div> - </div> - - <div style={{ borderTop: `1px solid ${COLORS.border}`, margin: "4px 0" }} /> - - {/* 5. Additional Options */} - <div className="space-y-3"> - {/* Decision Timeout Cap */} - <label className="block space-y-1"> - <span style={dlgLabelStyle}>DECISION TIMEOUT CAP</span> - <select - value={draft.modelConfig.decisionTimeoutCapHours ?? 24} - onChange={(e) => { - setDraft((p) => ({ - ...p, - modelConfig: { - ...p.modelConfig, - profileId: undefined, - decisionTimeoutCapHours: Number(e.target.value) as OrchestratorDecisionTimeoutCapHours - } - })); - }} - className="h-8 w-full px-3 text-xs outline-none" - style={dlgInputStyle} - > - {DECISION_TIMEOUT_CAP_OPTIONS.map((hours) => ( - <option key={hours} value={hours}> - {hours}h - </option> - ))} - </select> - </label> - - {/* c. Mission closeout */} - <div className="space-y-1"> - <span style={dlgLabelStyle}> - RESULT LANE CLOSEOUT - </span> - <div className="space-y-2 rounded-md border p-3" style={{ borderColor: COLORS.border, background: COLORS.recessedBg }}> - <div className="text-[11px]" style={{ color: COLORS.textPrimary }}> - ADE will finish this mission with one result lane containing the consolidated changes. - </div> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Worker lanes stay mission-scoped during execution. During closeout, ADE assembles an internal integration lane when multiple worker lanes need to be joined. - </div> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - GitHub PRs are not opened automatically. If you want an external PR later, create or link it from the result lane after review. - </div> - </div> - </div> - - {/* d. Phase Configuration */} - <div className="space-y-2"> - <div className="flex items-center justify-between gap-2"> - <span style={dlgLabelStyle}> - <Hash size={12} weight="bold" className="inline mr-1 -mt-0.5" style={{ color: COLORS.textMuted }} /> - PHASE CONFIGURATION - </span> - <select - value={phaseProfileSelectValue} - onChange={(e) => { - const nextProfileId = e.target.value || null; - if (nextProfileId === "__modified__") return; - const profile = phaseProfiles.find((entry) => entry.id === nextProfileId) ?? null; - setDraft((prev) => ({ - ...prev, - phaseProfileId: nextProfileId, - phaseOverride: profile - ? profile.phases.map((phase, index) => ({ ...phase, position: index })) - : prev.phaseOverride - })); - }} - className="h-7 w-[220px] px-2 text-[10px] outline-none" - style={dlgInputStyle} - disabled={phaseLoading || phaseProfiles.length === 0} - > - <option value="">Select profile</option> - {phaseProfileModified && selectedPhaseProfile ? ( - <option value="__modified__">Modified from {selectedPhaseProfile.name}</option> - ) : null} - {phaseProfiles.map((profile) => ( - <option key={profile.id} value={profile.id}> - {profile.isBuiltIn ? "\u25CF " : ""}{profile.name}{profile.description ? ` — ${profile.description}` : ""} - </option> - ))} - </select> - </div> - <div className="flex flex-wrap items-center gap-2"> - <button - type="button" - style={outlineButton()} - onClick={() => appendCustomPhase("generic")} - > - <Plus size={12} weight="bold" /> - ADD CUSTOM PHASE - </button> - <button - type="button" - style={outlineButton()} - onClick={() => appendCustomPhase("slash-command")} - > - <Plus size={12} weight="bold" /> - ADD SLASH COMMAND - </button> - <button - type="button" - style={outlineButton()} - onClick={() => appendCustomPhase("review")} - > - <Plus size={12} weight="bold" /> - ADD REVIEW PHASE - </button> - <select - value={selectedPhaseItemKey} - onChange={(e) => setSelectedPhaseItemKey(e.target.value)} - className="h-7 min-w-[220px] px-2 text-[10px] outline-none" - style={dlgInputStyle} - disabled={phaseItemsLoading || customPhaseItems.length === 0} - title="Pick a saved phase item to add to this mission." - > - <option value="">Add saved phase item...</option> - {customPhaseItems.map((item) => ( - <option key={item.phaseKey} value={item.phaseKey}> - {item.name} ({item.phaseKey}) - </option> - ))} - </select> - <button - type="button" - style={outlineButton()} - disabled={!selectedPhaseItemKey} - onClick={() => { - const item = customPhaseItems.find((entry) => entry.phaseKey === selectedPhaseItemKey); - if (!item) return; - appendPhaseFromItem(item); - setPhaseNotice(`Added phase item "${item.name}".`); - setSelectedPhaseItemKey(""); - }} - > - ADD ITEM - </button> - <button - type="button" - style={outlineButton()} - disabled={draft.phaseOverride.length === 0} - onClick={async () => { - const profileName = window.prompt("New phase profile name", "Custom Profile"); - if (!profileName || !profileName.trim()) return; - try { - const saved = await window.ade.missions.savePhaseProfile({ - profile: { - name: profileName.trim(), - description: "Saved from mission launch flow", - phases: draft.phaseOverride - } - }); - setPhaseProfiles((prev) => { - const next = [saved, ...prev.filter((entry) => entry.id !== saved.id)]; - setCachedPhaseProfiles(next); - return next; - }); - setDraft((prev) => ({ ...prev, phaseProfileId: saved.id })); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } - }} - > - SAVE AS PROFILE - </button> - <button - type="button" - style={outlineButton()} - disabled={phaseItemsLoading} - onClick={async () => { - const filePath = window.prompt("Path to phase items JSON", ""); - if (!filePath || !filePath.trim()) return; - try { - const imported = await window.ade.missions.importPhaseItems({ filePath: filePath.trim() }); - setPhaseItems((prev) => { - const map = new Map(prev.map((item) => [item.phaseKey, item] as const)); - for (const item of imported) map.set(item.phaseKey, item); - const next = Array.from(map.values()); - setCachedPhaseItems(next); - return next; - }); - setPhaseNotice(`Imported ${imported.length} phase item${imported.length === 1 ? "" : "s"}.`); - setPhaseItemsError(null); - } catch (err) { - setPhaseItemsError(err instanceof Error ? err.message : String(err)); - } - }} - > - IMPORT ITEMS - </button> - <button - type="button" - style={outlineButton()} - disabled={phaseItemsLoading} - onClick={async () => { - try { - const exported = await window.ade.missions.exportPhaseItems({}); - setPhaseNotice( - exported.savedPath - ? `Exported ${exported.items.length} phase items to ${exported.savedPath}.` - : `Exported ${exported.items.length} phase items.` - ); - setPhaseItemsError(null); - } catch (err) { - setPhaseItemsError(err instanceof Error ? err.message : String(err)); - } - }} - > - EXPORT ITEMS - </button> - <button - type="button" - style={outlineButton()} - disabled={draft.phaseOverride.length === 0} - onClick={async () => { - const reusable = draft.phaseOverride.filter((phase) => !phase.isBuiltIn); - if (reusable.length === 0) { - setPhaseNotice("No custom phases to save as reusable items."); - return; - } - try { - const saved: PhaseCard[] = []; - for (const item of reusable) { - // Save each non-built-in phase card as a reusable phase item. - const next = await window.ade.missions.savePhaseItem({ item: { ...item, isBuiltIn: false, isCustom: true } }); - saved.push(next); - } - setPhaseItems((prev) => { - const map = new Map(prev.map((item) => [item.phaseKey, item] as const)); - for (const item of saved) map.set(item.phaseKey, item); - const next = Array.from(map.values()); - setCachedPhaseItems(next); - return next; - }); - setPhaseNotice(`Saved ${saved.length} reusable phase item${saved.length === 1 ? "" : "s"}.`); - setPhaseItemsError(null); - } catch (err) { - setPhaseItemsError(err instanceof Error ? err.message : String(err)); - } - }} - > - SAVE CUSTOM ITEMS - </button> - </div> - {phaseLoading ? ( - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Loading phase profiles... - </div> - ) : null} - {phaseItemsLoading ? ( - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Loading phase items... - </div> - ) : null} - {phaseError ? ( - <div className="px-2 py-1 text-[10px]" style={{ background: "color-mix(in srgb, var(--color-error) 15%, transparent)", border: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", color: COLORS.danger }}> - {phaseError} - </div> - ) : null} - {phaseItemsError ? ( - <div className="px-2 py-1 text-[10px]" style={{ background: "color-mix(in srgb, var(--color-error) 15%, transparent)", border: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", color: COLORS.danger }}> - {phaseItemsError} - </div> - ) : null} - {phaseNotice ? ( - <div className="px-2 py-1 text-[10px]" style={{ background: "color-mix(in srgb, var(--color-success) 14%, transparent)", border: "1px solid color-mix(in srgb, var(--color-success) 30%, transparent)", color: COLORS.success }}> - {phaseNotice} - </div> - ) : null} - <div className="space-y-1.5"> - {draft.phaseOverride.map((phase, index) => { - const isPlanningPhase = phase.phaseKey.trim().toLowerCase() === "planning"; - return ( - <PhaseCardEditor - key={phase.id} - phase={phase} - index={index} - totalCount={draft.phaseOverride.length} - expanded={expandedPhases[phase.id] === true} - readOnly={false} - showToggle={!isPlanningPhase} - disabled={isPlanningPhase ? false : disabledPhases[phase.id] === true} - onToggleDisabled={isPlanningPhase ? undefined : () => setDisabledPhases((prev) => ({ ...prev, [phase.id]: !prev[phase.id] }))} - onToggleExpand={() => setExpandedPhases((prev) => ({ ...prev, [phase.id]: !prev[phase.id] }))} - onUpdate={(updated) => { - setDraft((prev) => ({ - ...prev, - phaseOverride: prev.phaseOverride.map((entry) => - entry.id === updated.id ? updated : entry - ), - })); - }} - onMoveUp={() => { - if (index === 0) return; - setDraft((prev) => { - const next = [...prev.phaseOverride]; - const moved = next[index]; - if (!moved) return prev; - next.splice(index, 1); - next.splice(index - 1, 0, moved); - return { ...prev, phaseOverride: next.map((entry, pos) => ({ ...entry, position: pos })) }; - }); - }} - onMoveDown={() => { - setDraft((prev) => { - if (index >= prev.phaseOverride.length - 1) return prev; - const next = [...prev.phaseOverride]; - const moved = next[index]; - if (!moved) return prev; - next.splice(index, 1); - next.splice(index + 1, 0, moved); - return { ...prev, phaseOverride: next.map((entry, pos) => ({ ...entry, position: pos })) }; - }); - }} - onRemove={phase.isCustom ? () => { - setDraft((prev) => ({ - ...prev, - phaseOverride: prev.phaseOverride - .filter((entry) => entry.id !== phase.id) - .map((entry, pos) => ({ ...entry, position: pos })), - })); - setExpandedPhases((prev) => { const n = { ...prev }; delete n[phase.id]; return n; }); - setDisabledPhases((prev) => { const n = { ...prev }; delete n[phase.id]; return n; }); - } : undefined} - availableModelIds={availableModelIds} - labelStyle={dlgLabelStyle} - inputStyle={dlgInputStyle} - planningPromptPreview={{ - missionPrompt: draft.prompt, - phases: draft.phaseOverride, - }} - /> - ); - })} - </div> - {phaseValidationErrors.length > 0 ? ( - <div className="space-y-1 px-2 py-1.5" style={{ background: "color-mix(in srgb, var(--color-warning) 15%, transparent)", border: "1px solid color-mix(in srgb, var(--color-warning) 30%, transparent)", color: COLORS.warning }}> - {phaseValidationErrors.map((entry) => ( - <div key={entry} className="text-[10px]" style={{ fontFamily: MONO_FONT }}> - {entry} - </div> - ))} - </div> - ) : null} - </div> - - {showAdvancedLaunchControls ? ( - <> - {/* e. Smart Token Budget */} - <SmartBudgetPanel - value={draft.modelConfig.smartBudget ?? { enabled: false, fiveHourThresholdUsd: 10, weeklyThresholdUsd: 50 }} - onChange={(config) => setDraft((p) => ({ - ...p, - modelConfig: { ...p.modelConfig, smartBudget: config } - }))} - currentSpend={selectedBudgetFamilies.hasApiModels && currentUsage ? { - fiveHourUsd: currentUsage.summary.totalCostEstimateUsd, - weeklyUsd: weeklyUsage?.summary.totalCostEstimateUsd ?? currentUsage.summary.totalCostEstimateUsd, - } : null} - modelUsage={selectedBudgetFamilies.hasApiModels && currentUsage?.byModel?.length ? Object.fromEntries( - currentUsage.byModel.map((m) => [m.model, { - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - costUsd: m.costEstimateUsd, - sessions: m.sessions, - }]) - ) : undefined} - billingContext={billingContext} - perProvider={launcherPerProviderUsage} - /> - - <div className="space-y-1.5"> - <span style={dlgLabelStyle}>AGENT RUNTIME CAPABILITIES</span> - <label className="flex items-center gap-2 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={draft.agentRuntime.allowParallelAgents} - onChange={(e) => { - const checked = e.target.checked; - setDraft((p) => ({ - ...p, - agentRuntime: { ...p.agentRuntime, allowParallelAgents: checked }, - ...(p.teamRuntime - ? { teamRuntime: { ...p.teamRuntime, allowParallelAgents: checked } } - : {}) - })); - }} - /> - ALLOW PARALLEL AGENTS / WORKERS - </label> - <label className="flex items-center gap-2 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={draft.agentRuntime.allowSubAgents} - onChange={(e) => { - const checked = e.target.checked; - setDraft((p) => ({ - ...p, - agentRuntime: { ...p.agentRuntime, allowSubAgents: checked }, - ...(p.teamRuntime - ? { teamRuntime: { ...p.teamRuntime, allowSubAgents: checked } } - : {}) - })); - }} - /> - ALLOW SUB-AGENTS (NESTED DELEGATION) - </label> - <label className="flex items-center gap-2 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={draft.agentRuntime.allowClaudeAgentTeams} - onChange={(e) => { - const checked = e.target.checked; - setDraft((p) => ({ - ...p, - agentRuntime: { ...p.agentRuntime, allowClaudeAgentTeams: checked }, - ...(p.teamRuntime - ? { teamRuntime: { ...p.teamRuntime, allowClaudeAgentTeams: checked } } - : {}) - })); - }} - /> - ALLOW CLAUDE CODE AGENT TEAMS - </label> - <div style={{ fontSize: 9, color: COLORS.textDim, fontFamily: MONO_FONT, marginTop: 2 }}> - These controls are passed to orchestrator strategy prompts and runtime metadata. - </div> - </div> - - <div className="space-y-2"> - <label className="flex items-center gap-2 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={draft.teamRuntime?.enabled ?? false} - title="Enable teammate worker orchestration for this mission." - onChange={(e) => setDraft((p) => ({ - ...p, - teamRuntime: { - enabled: e.target.checked, - targetProvider: p.teamRuntime?.targetProvider ?? "auto", - teammateCount: p.teamRuntime?.teammateCount ?? 2, - allowParallelAgents: p.agentRuntime.allowParallelAgents, - allowSubAgents: p.agentRuntime.allowSubAgents, - allowClaudeAgentTeams: p.agentRuntime.allowClaudeAgentTeams, - } - }))} - /> - ENABLE TEAM RUNTIME - </label> - <div className="pl-5 text-[9px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Team runtime lets the orchestrator coordinate additional teammate workers for parallel execution. - </div> - {draft.teamRuntime?.enabled && ( - <div className="flex items-center gap-3 pl-5"> - <label className="flex items-center gap-1.5 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>TEAMMATES</span> - <input - type="number" - min={1} - max={8} - value={draft.teamRuntime?.teammateCount ?? 2} - title="Number of teammate workers available to the orchestrator." - onChange={(e) => setDraft((p) => ({ - ...p, - teamRuntime: { - ...p.teamRuntime!, - teammateCount: Math.max(1, Math.min(8, Number(e.target.value) || 2)), - } - }))} - className="h-6 w-12 px-1 text-xs text-center outline-none" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.outlineBorder}`, color: COLORS.textPrimary, fontFamily: MONO_FONT, borderRadius: 0 }} - /> - </label> - <label className="flex items-center gap-1.5 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>PROVIDER</span> - <select - value={draft.teamRuntime?.targetProvider ?? "auto"} - title="Preferred provider family for teammate workers." - onChange={(e) => setDraft((p) => ({ - ...p, - teamRuntime: { - ...p.teamRuntime!, - targetProvider: e.target.value as "claude" | "codex" | "auto", - } - }))} - className="h-6 px-1 text-xs outline-none" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.outlineBorder}`, color: COLORS.textPrimary, fontFamily: MONO_FONT, borderRadius: 0 }} - > - <option value="auto">Auto</option> - <option value="claude">Claude</option> - <option value="codex">Codex</option> - </select> - </label> - </div> - )} - {teamBudgetGuardrailActive ? ( - <div - className="ml-5 flex items-center gap-1" - style={{ fontSize: 10, color: "#F59E0B", fontFamily: MONO_FONT }} - > - <Warning size={12} weight="bold" /> - {`${teamBudgetGuardrailTeammateCount} teammates with Smart Budget disabled. Launch requires explicit confirmation.`} - </div> - ) : null} - </div> - - {/* Worker Permissions — per-model-family (placed last so all model selections are finalized) */} - <WorkerPermissionsSection - draft={draft} - activePhases={activePhases} - setDraft={setDraft} - dlgLabelStyle={dlgLabelStyle} - dlgInputStyle={dlgInputStyle} - /> - - </> - ) : ( - <div className="space-y-1.5 p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div - className="text-[10px] font-bold uppercase tracking-[1px]" - style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }} - > - Loading advanced launch controls... - </div> - <div className="text-[11px]" style={{ color: COLORS.textSecondary }}> - Budget guardrails, teammate runtime, worker permissions, and computer-use options will load after the dialog settles. - </div> - </div> - )} - - {(preflightCurrent || preflightError) ? ( - <div - className="space-y-2 p-3" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }} - > - <div className="flex items-center justify-between gap-3"> - <div> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Launch Review - </div> - <div className="mt-1 text-[12px]" style={{ color: COLORS.textPrimary }}> - {preflightCurrent?.approvalSummary?.missionGoal ?? "Review launch assumptions before starting the mission."} - </div> - </div> - {preflightCurrent ? ( - <div - className="px-2 py-1 text-[10px] font-bold uppercase tracking-[1px]" - style={{ - color: preflightCurrent.canLaunch ? COLORS.success : COLORS.warning, - border: `1px solid ${preflightCurrent.canLaunch ? "color-mix(in srgb, var(--color-success) 35%, transparent)" : "color-mix(in srgb, var(--color-warning) 35%, transparent)"}`, - background: preflightCurrent.canLaunch ? "color-mix(in srgb, var(--color-success) 12%, transparent)" : "color-mix(in srgb, var(--color-warning) 12%, transparent)", - fontFamily: MONO_FONT, - }} - > - {preflightCurrent.canLaunch ? "Preflight passed" : "Fix launch blockers"} - </div> - ) : null} - </div> - - {preflightCurrent?.approvalSummary ? ( - <div className="grid gap-2 md:grid-cols-2"> - <div className="space-y-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <div>Lane: {preflightCurrent.approvalSummary.laneLabel ?? preflightCurrent.approvalSummary.laneId ?? "default active lane"}</div> - <div>Execution: {preflightCurrent.approvalSummary.recommendedExecution.strategy}</div> - <div>Closeout: one result lane</div> - <div>Model: {preflightCurrent.approvalSummary.recommendedExecution.orchestratorModelId ?? "not configured"}</div> - </div> - <div className="space-y-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <div>Phases: {preflightCurrent.approvalSummary.phaseLabels.join(" -> ")}</div> - <div>Warnings: {preflightCurrent.warnings}</div> - <div>Failures: {preflightCurrent.hardFailures}</div> - </div> - </div> - ) : null} - - {preflightError ? ( - <div className="text-[10px]" style={{ color: COLORS.danger, fontFamily: MONO_FONT }}> - {preflightError} - </div> - ) : null} - - {checklistFailures.length > 0 ? ( - <div className="space-y-1"> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.warning, fontFamily: MONO_FONT }}> - Blocking Issues - </div> - {checklistFailures.slice(0, 4).map((item) => ( - <div key={item.id} className="text-[10px]" style={{ color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - {item.title}: {item.summary} - </div> - ))} - </div> - ) : null} - - {checklistWarnings.length > 0 ? ( - <div className="space-y-1"> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Runtime Warnings - </div> - {checklistWarnings.slice(0, 3).map((item) => ( - <div key={item.id} className="text-[10px]" style={{ color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - {item.title}: {item.summary} - </div> - ))} - </div> - ) : null} - </div> - ) : null} - </div> - - </div> - - <div className="flex shrink-0 flex-col gap-2 px-5 py-3 md:flex-row md:items-center md:justify-between" style={{ borderTop: `1px solid ${COLORS.border}` }}> - <div className="min-h-4 flex-1 text-[10px]" style={{ color: launchBlockedReason ? COLORS.warning : COLORS.textMuted, fontFamily: MONO_FONT }}> - {launchFooterMessage} - </div> - <div className="flex items-center justify-end gap-2"> - <button style={outlineButton()} onClick={onClose} disabled={busy}>CANCEL</button> - <button - style={primaryButton()} - onClick={handleLaunch} - disabled={launchDisabled} - > - {busy || preflightLoading ? <SpinnerGap className="h-3.5 w-3.5 animate-spin" /> : <Rocket className="h-3.5 w-3.5" />} - {preflightCurrent - ? (preflightCurrent.canLaunch ? "LAUNCH MISSION" : "FIX FAILURES") - : "REVIEW LAUNCH"} - </button> - </div> - </div> - </motion.div> - </div> - ); -} - -export const CreateMissionDialog = React.memo(CreateMissionDialogInner); diff --git a/apps/desktop/src/renderer/components/missions/InterventionPanel.tsx b/apps/desktop/src/renderer/components/missions/InterventionPanel.tsx deleted file mode 100644 index d5ac314f7..000000000 --- a/apps/desktop/src/renderer/components/missions/InterventionPanel.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { useMemo, useState, useCallback, useEffect } from "react"; -import { Clock, WarningCircle, Check, X, Copy, Eye } from "@phosphor-icons/react"; -import type { MissionIntervention, ClarificationQuestion } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT, primaryButton, outlineButton } from "../lanes/laneDesignTokens"; -import { useMissionsStore, useMissionsStoreApi } from "./useMissionsStore"; -import { getMissionInterventionOwnerLabel, isRecord } from "./missionHelpers"; -import { relativeWhen } from "../../lib/format"; -import { routeMissionIntervention } from "./missionInterventionRouting"; - -/* ════════════════════ INTERVENTION HELPERS ════════════════════ */ - -function isQuizIntervention( - intervention: MissionIntervention | null | undefined, -): intervention is MissionIntervention & { - metadata: Record<string, unknown> & { quizMode: true; questions: ClarificationQuestion[] }; -} { - return Boolean( - intervention && - intervention.interventionType === "manual_input" && - intervention.status === "open" && - intervention.metadata?.quizMode === true && - Array.isArray(intervention.metadata?.questions), - ); -} - -function isBlockingManualInputIntervention(intervention: MissionIntervention): boolean { - if (intervention.interventionType !== "manual_input" || intervention.status !== "open") return false; - return intervention.metadata?.canProceedWithoutAnswer !== true; -} - -function isSystemLaunchFailureIntervention(intervention: MissionIntervention): boolean { - return intervention.metadata?.reasonCode === "mission_launch_failed"; -} - -function interventionPriority(intervention: MissionIntervention): number { - if (isBlockingManualInputIntervention(intervention)) return 300; - if (isSystemLaunchFailureIntervention(intervention)) return 200; - if ( - intervention.metadata?.reasonCode === "coordinator_unavailable" - || intervention.metadata?.reasonCode === "coordinator_recovery_failed" - ) { - return 100; - } - return 0; -} - -function buildTechnicalDetails(intervention: MissionIntervention): string[] { - const details: string[] = []; - const metadata = isRecord(intervention.metadata) ? intervention.metadata : null; - if (!metadata) return details; - - const failureStageLabel = - typeof metadata.failureStageLabel === "string" && metadata.failureStageLabel.trim().length > 0 - ? metadata.failureStageLabel.trim() - : typeof metadata.failureStage === "string" && metadata.failureStage.trim().length > 0 - ? metadata.failureStage.trim() - : ""; - const rootError = typeof metadata.rootError === "string" ? metadata.rootError.trim() : ""; - const coordinatorState = typeof metadata.coordinatorState === "string" ? metadata.coordinatorState.trim() : ""; - - if (failureStageLabel) details.push(`Stage: ${failureStageLabel}`); - if (rootError) details.push(`Error: ${rootError}`); - if (coordinatorState) details.push(`Coordinator: ${coordinatorState.replace(/_/g, " ")}`); - - return details; -} - -export { isQuizIntervention, isBlockingManualInputIntervention }; - -/* ════════════════════ INTERVENTION PANEL (VAL-UX-005) ════════════════════ */ - -/** - * Dedicated intervention panel in the detail view. - * Shows title, type badge, timestamp, response input, and resolve/dismiss buttons. - * Replaces modal auto-open for a better inline UX. (VAL-UX-005) - */ -export function InterventionPanel({ compact }: { compact: boolean }) { - const missionsStore = useMissionsStoreApi(); - const selectedMission = useMissionsStore((s) => s.selectedMission); - const steerBusy = useMissionsStore((s) => s.steerBusy); - const activeInterventionId = useMissionsStore((s) => s.activeInterventionId); - const setActiveInterventionId = useMissionsStore((s) => s.setActiveInterventionId); - - const openInterventions = useMemo( - () => - (selectedMission?.interventions.filter((i) => i.status === "open") ?? []) - .slice() - .sort((left, right) => { - const priorityDelta = interventionPriority(right) - interventionPriority(left); - if (priorityDelta !== 0) return priorityDelta; - return Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt); - }), - [selectedMission], - ); - - const primaryIntervention = useMemo(() => { - if (openInterventions.length === 0) return null; - const active = activeInterventionId - ? openInterventions.find((intervention) => intervention.id === activeInterventionId) ?? null - : null; - if (active) return active; - return openInterventions.find(isBlockingManualInputIntervention) ?? openInterventions[0] ?? null; - }, [activeInterventionId, openInterventions]); - - const [responseText, setResponseText] = useState(""); - - useEffect(() => { - setResponseText(""); - }, [primaryIntervention?.id]); - - const handleResolve = useCallback( - async (interventionId: string) => { - const s = missionsStore.getState(); - if (!s.selectedMission) return; - const intervention = s.selectedMission.interventions.find((entry) => entry.id === interventionId) ?? null; - if (isQuizIntervention(intervention)) { - setActiveInterventionId(interventionId); - return; - } - if (!intervention) return; - const defaultDirective = intervention.interventionType === "manual_input" - ? "Acknowledged." - : intervention.requestedAction || "Continue with the safest available resolution."; - s.setSteerBusy(true); - try { - await window.ade.orchestrator.steerMission({ - missionId: s.selectedMission.id, - interventionId, - directive: responseText.trim() || defaultDirective, - priority: "instruction", - resolutionKind: "answer_provided", - }); - setResponseText(""); - await s.refreshMissionList({ preserveSelection: true, silent: true }); - await s.loadMissionDetail(s.selectedMission.id); - await s.loadOrchestratorGraph(s.selectedMission.id); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setSteerBusy(false); - } - }, - [missionsStore, responseText, setActiveInterventionId], - ); - - const handleDismiss = useCallback( - async (interventionId: string) => { - const s = missionsStore.getState(); - if (!s.selectedMission) return; - s.setSteerBusy(true); - try { - await window.ade.orchestrator.steerMission({ - missionId: s.selectedMission.id, - interventionId, - directive: "Skip this question and continue with best-effort assumptions.", - priority: "instruction", - resolutionKind: "skip_question", - }); - await s.refreshMissionList({ preserveSelection: true, silent: true }); - await s.loadMissionDetail(s.selectedMission.id); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setSteerBusy(false); - } - }, - [missionsStore], - ); - - if (!selectedMission || !primaryIntervention) return null; - - const remainingOpenCount = Math.max(0, openInterventions.length - 1); - - return ( - <div style={{ margin: compact ? "8px 12px" : "12px 16px" }}> - <InterventionCard - intervention={primaryIntervention} - compact={compact} - responseText={responseText} - onResponseChange={setResponseText} - onResolve={handleResolve} - onDismiss={handleDismiss} - busy={steerBusy} - remainingOpenCount={remainingOpenCount} - /> - </div> - ); -} - -/* ────────── Individual Intervention Card ────────── */ - -function InterventionCard({ - intervention, - compact, - responseText, - onResponseChange, - onResolve, - onDismiss, - busy, - remainingOpenCount, -}: { - intervention: MissionIntervention; - compact: boolean; - responseText: string; - onResponseChange: (v: string) => void; - onResolve: (id: string) => Promise<void>; - onDismiss: (id: string) => Promise<void>; - busy: boolean; - remainingOpenCount: number; -}) { - const missionsStore = useMissionsStoreApi(); - const isBlocking = isBlockingManualInputIntervention(intervention); - const isSystemLaunchFailure = isSystemLaunchFailureIntervention(intervention); - const borderColor = isBlocking ? COLORS.warning : COLORS.accentBorder; - const iconColor = isBlocking ? COLORS.warning : COLORS.accent; - const ownerLabel = getMissionInterventionOwnerLabel(intervention); - const technicalDetails = buildTechnicalDetails(intervention); - const stackTrace = typeof intervention.metadata?.rootErrorStack === "string" - ? intervention.metadata.rootErrorStack.trim() - : ""; - const isManualInput = intervention.interventionType === "manual_input"; - const canRespondInline = isManualInput || Boolean(intervention.requestedAction); - const responsePlaceholder = isManualInput - ? "Type your response..." - : "Tell the coordinator to retry, skip, or apply a workaround..."; - - const typeLabel = intervention.interventionType.replace(/_/g, " "); - - const handleViewDetails = useCallback(() => { - const s = missionsStore.getState(); - routeMissionIntervention(s, intervention); - }, [intervention, missionsStore]); - - const handleCopyError = useCallback(async () => { - const parts = [ - intervention.title, - intervention.body, - ...technicalDetails, - stackTrace, - intervention.requestedAction ? `Requested action: ${intervention.requestedAction}` : "", - ].filter((entry) => entry.trim().length > 0); - const text = parts.join("\n\n"); - try { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - } - } catch { - // Ignore clipboard failures in the inline panel. - } - }, [intervention.body, intervention.requestedAction, intervention.title, stackTrace, technicalDetails]); - - return ( - <div - style={{ - background: COLORS.cardBg, - border: `1px solid ${borderColor}`, - padding: compact ? "8px 12px" : "12px 16px", - marginBottom: 8, - wordBreak: "break-word", - }} - > - {/* Header: title + type badge + timestamp */} - <div style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 8 }}> - <WarningCircle - weight="bold" - style={{ color: iconColor, width: 16, height: 16, flexShrink: 0, marginTop: 1 }} - /> - <div style={{ flex: 1, minWidth: 0 }}> - {ownerLabel ? ( - <div - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "0.8px", - color: iconColor, - marginBottom: 4, - }} - > - {ownerLabel} - </div> - ) : null} - <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}> - <span - style={{ - fontFamily: SANS_FONT, - fontSize: 12, - fontWeight: 600, - color: COLORS.textPrimary, - }} - > - {intervention.title} - </span> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "1px", - color: iconColor, - background: `${iconColor}18`, - border: `1px solid ${iconColor}30`, - padding: "1px 6px", - }} - > - {typeLabel} - </span> - </div> - <div - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - color: COLORS.textDim, - marginTop: 4, - display: "flex", - alignItems: "center", - gap: 4, - }} - > - <Clock size={10} /> - {relativeWhen(intervention.createdAt)} - </div> - </div> - </div> - - <div - style={{ - fontFamily: SANS_FONT, - fontSize: compact ? 11 : 12, - lineHeight: "1.55", - color: COLORS.textSecondary, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} - > - {intervention.body} - </div> - - {technicalDetails.length > 0 ? ( - <div - style={{ - marginTop: 8, - padding: compact ? "6px 8px" : "8px 10px", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - }} - > - <div - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "0.8px", - color: COLORS.textDim, - marginBottom: 4, - }} - > - Technical details - </div> - <div - style={{ - fontFamily: MONO_FONT, - fontSize: compact ? 10 : 11, - lineHeight: "1.5", - color: COLORS.textPrimary, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} - > - {technicalDetails.join("\n")} - </div> - {stackTrace ? ( - <details style={{ marginTop: 6 }}> - <summary - style={{ - cursor: "pointer", - fontFamily: MONO_FONT, - fontSize: 9, - color: COLORS.textDim, - }} - > - Stack trace - </summary> - <pre - style={{ - marginTop: 6, - fontFamily: MONO_FONT, - fontSize: 10, - lineHeight: "1.4", - color: COLORS.textSecondary, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} - > - {stackTrace} - </pre> - </details> - ) : null} - </div> - ) : null} - - {intervention.requestedAction ? ( - <div - style={{ - marginTop: 8, - padding: compact ? "6px 8px" : "8px 10px", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - }} - > - <div - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "0.8px", - color: COLORS.textDim, - marginBottom: 4, - }} - > - Requested action - </div> - <div - style={{ - fontFamily: SANS_FONT, - fontSize: compact ? 10 : 11, - lineHeight: "1.45", - color: COLORS.textPrimary, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} - > - {intervention.requestedAction} - </div> - </div> - ) : null} - - {remainingOpenCount > 0 ? ( - <div - style={{ - marginTop: 8, - fontFamily: MONO_FONT, - fontSize: 9, - textTransform: "uppercase", - letterSpacing: "0.8px", - color: COLORS.textDim, - }} - > - +{remainingOpenCount} more open intervention{remainingOpenCount === 1 ? "" : "s"} in this mission - </div> - ) : null} - - {/* Response input */} - {canRespondInline && ( - <div style={{ marginTop: 8 }}> - <textarea - value={responseText} - onChange={(e) => onResponseChange(e.target.value)} - placeholder={responsePlaceholder} - rows={2} - style={{ - width: "100%", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - fontSize: 11, - padding: "8px 12px", - resize: "vertical", - outline: "none", - }} - /> - </div> - )} - - {/* Action buttons */} - <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}> - {canRespondInline ? ( - <> - <button - style={primaryButton({ height: 28, padding: "0 12px", fontSize: 10 })} - onClick={() => void onResolve(intervention.id)} - disabled={busy} - > - <Check size={12} /> - {isManualInput ? "RESOLVE" : "SEND DECISION"} - </button> - {!isBlocking && isManualInput ? ( - <button - style={outlineButton({ height: 28, padding: "0 12px", fontSize: 10 })} - onClick={() => void onDismiss(intervention.id)} - disabled={busy} - > - <X size={12} /> - DISMISS - </button> - ) : null} - {!isManualInput ? ( - <button - style={outlineButton({ height: 28, padding: "0 12px", fontSize: 10 })} - onClick={handleViewDetails} - disabled={busy} - > - <Eye size={12} /> - VIEW DETAILS - </button> - ) : null} - </> - ) : ( - <> - <button - style={primaryButton({ height: 28, padding: "0 12px", fontSize: 10 })} - onClick={handleViewDetails} - disabled={busy} - > - <Eye size={12} /> - VIEW DETAILS - </button> - {isSystemLaunchFailure ? ( - <button - style={outlineButton({ height: 28, padding: "0 12px", fontSize: 10 })} - onClick={() => void handleCopyError()} - disabled={busy} - > - <Copy size={12} /> - COPY ERROR - </button> - ) : null} - </> - )} - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/ManageMissionDialog.tsx b/apps/desktop/src/renderer/components/missions/ManageMissionDialog.tsx deleted file mode 100644 index b60ced45d..000000000 --- a/apps/desktop/src/renderer/components/missions/ManageMissionDialog.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useCallback, useEffect } from "react"; -import { X, Stop, Trash, SpinnerGap, Play, Pause } from "@phosphor-icons/react"; -import { motion, AnimatePresence } from "motion/react"; -import type { MissionSummary } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT, outlineButton, dangerButton } from "../lanes/laneDesignTokens"; -import { LIFECYCLE_ACTIONS, STATUS_CONFIG, TERMINAL_MISSION_STATUSES } from "./missionHelpers"; -import { useMissionsStore } from "./useMissionsStore"; -import { LaneAccentDot } from "../lanes/LaneAccentDot"; -import { useAppStore } from "../../state/appStore"; - -/* ════════════════════ MANAGE MISSION DIALOG ════════════════════ */ - -export function ManageMissionDialog() { - const manageMission = useMissionsStore((s) => s.manageMission); - const manageMissionOpen = useMissionsStore((s) => s.manageMissionOpen); - const manageMissionBusy = useMissionsStore((s) => s.manageMissionBusy); - const manageMissionError = useMissionsStore((s) => s.manageMissionError); - const manageMissionCleanupLanes = useMissionsStore((s) => s.manageMissionCleanupLanes); - - const setManageMissionOpen = useMissionsStore((s) => s.setManageMissionOpen); - const setManageMissionError = useMissionsStore((s) => s.setManageMissionError); - const setManageMissionCleanupLanes = useMissionsStore((s) => s.setManageMissionCleanupLanes); - const setManageMission = useMissionsStore((s) => s.setManageMission); - const setManageMissionBusy = useMissionsStore((s) => s.setManageMissionBusy); - const refreshMissionList = useMissionsStore((s) => s.refreshMissionList); - const loadDashboard = useMissionsStore((s) => s.loadDashboard); - const setError = useMissionsStore((s) => s.setError); - - const baseLaneColor = useAppStore((s) => { - const laneId = manageMission?.laneId ?? null; - if (!laneId) return null; - return s.lanes.find((l) => l.id === laneId)?.color ?? null; - }); - - const closeDialog = useCallback( - (force = false) => { - if (manageMissionBusy && !force) return; - setManageMissionOpen(false); - setManageMissionError(null); - setManageMissionCleanupLanes(false); - setManageMission(null); - }, - [manageMissionBusy, setManageMission, setManageMissionCleanupLanes, setManageMissionError, setManageMissionOpen], - ); - - const handleArchive = useCallback(async () => { - if (!manageMission) return; - if (!TERMINAL_MISSION_STATUSES.has(manageMission.status)) { - setManageMissionError("Only completed, failed, or canceled missions can be archived."); - return; - } - const message = manageMissionCleanupLanes - ? "Archive this mission and also archive lanes created by this mission? This removes the mission from the Missions UI and archives managed lanes ADE can find across this mission's runs." - : "Archive this mission? This will remove it from the Missions UI."; - if (!window.confirm(message)) return; - setManageMissionBusy(true); - setManageMissionError(null); - try { - let cleanupWarning: string | null = null; - if (manageMissionCleanupLanes) { - const result = await window.ade.orchestrator.cleanupTeamResources({ - missionId: manageMission.id, cleanupLanes: true, - }); - if (result.laneErrors.length > 0) { - cleanupWarning = `Mission archived, but lane cleanup archived ${result.lanesArchived.length}/${result.laneIds.length} lane(s). ${result.laneErrors.length} lane(s) failed to archive.`; - } - } - await window.ade.missions.archive({ missionId: manageMission.id }); - closeDialog(true); - await Promise.all([refreshMissionList({ preserveSelection: true, silent: true }), loadDashboard()]); - setError(cleanupWarning); - } catch (err) { - setManageMissionError(err instanceof Error ? err.message : String(err)); - } finally { - setManageMissionBusy(false); - } - }, [closeDialog, loadDashboard, manageMission, manageMissionCleanupLanes, refreshMissionList, setError, setManageMissionBusy, setManageMissionError]); - - const handleCancel = useCallback(async () => { - if (!manageMission || TERMINAL_MISSION_STATUSES.has(manageMission.status)) return; - const message = LIFECYCLE_ACTIONS.cancel_mission.confirmText ?? "Cancel this mission?"; - if (!window.confirm(message)) return; - setManageMissionBusy(true); - setManageMissionError(null); - try { - const runs = await window.ade.orchestrator.listRuns({ missionId: manageMission.id, limit: 5 }); - const activeRun = runs.find((r) => r.status === "active" || r.status === "paused" || r.status === "bootstrapping" || r.status === "queued"); - if (activeRun) await window.ade.orchestrator.cancelRun({ runId: activeRun.id, reason: "Canceled from Manage Mission dialog." }); - closeDialog(true); - await Promise.all([refreshMissionList({ preserveSelection: true, silent: true }), loadDashboard()]); - } catch (err) { - setManageMissionError(err instanceof Error ? err.message : String(err)); - } finally { - setManageMissionBusy(false); - } - }, [closeDialog, loadDashboard, manageMission, refreshMissionList, setManageMissionBusy, setManageMissionError]); - - const handlePause = useCallback(async () => { - if (!manageMission) return; - setManageMissionBusy(true); - setManageMissionError(null); - try { - const runs = await window.ade.orchestrator.listRuns({ missionId: manageMission.id, limit: 5 }); - const activeRun = runs.find((r) => r.status === "active" || r.status === "bootstrapping"); - if (!activeRun) throw new Error("No active run is available to pause."); - await window.ade.orchestrator.pauseRun({ runId: activeRun.id, reason: "Paused from Manage Mission dialog." }); - closeDialog(true); - await Promise.all([refreshMissionList({ preserveSelection: true, silent: true }), loadDashboard()]); - } catch (err) { - setManageMissionError(err instanceof Error ? err.message : String(err)); - } finally { - setManageMissionBusy(false); - } - }, [closeDialog, loadDashboard, manageMission, refreshMissionList, setManageMissionBusy, setManageMissionError]); - - const handleResume = useCallback(async () => { - if (!manageMission) return; - setManageMissionBusy(true); - setManageMissionError(null); - try { - const runs = await window.ade.orchestrator.listRuns({ missionId: manageMission.id, limit: 5 }); - const pausedRun = runs.find((r) => r.status === "paused"); - if (!pausedRun) throw new Error("No paused run is available to resume."); - await window.ade.orchestrator.resumeRun({ runId: pausedRun.id }); - closeDialog(true); - await Promise.all([refreshMissionList({ preserveSelection: true, silent: true }), loadDashboard()]); - } catch (err) { - setManageMissionError(err instanceof Error ? err.message : String(err)); - } finally { - setManageMissionBusy(false); - } - }, [closeDialog, loadDashboard, manageMission, refreshMissionList, setManageMissionBusy, setManageMissionError]); - - return ( - <AnimatePresence> - {manageMissionOpen && manageMission ? ( - <> - <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50" style={{ background: "rgba(2, 6, 23, 0.45)", backdropFilter: "blur(6px)" }} onClick={() => closeDialog(false)} /> - <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 12 }} className="fixed left-1/2 top-[16%] z-[60] w-[min(520px,calc(100vw-24px))] -translate-x-1/2 p-4" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, boxShadow: "0 16px 48px rgba(15, 23, 42, 0.32)" }} onClick={(e) => e.stopPropagation()}> - <div className="flex items-start justify-between gap-3"> - <div className="min-w-0"> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}>Manage Mission</div> - <div className="mt-1 text-sm font-semibold" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}>{manageMission.title}</div> - </div> - <button type="button" className="p-1 transition-colors" style={{ color: COLORS.textMuted }} onClick={() => closeDialog(false)} disabled={manageMissionBusy} aria-label="Close manage mission dialog"><X className="h-4 w-4" /></button> - </div> - <div className="mt-3 flex items-center gap-2 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span className="px-1.5 py-0.5 font-bold uppercase tracking-[1px]" style={{ background: STATUS_CONFIG[manageMission.status].background, color: STATUS_CONFIG[manageMission.status].color, border: STATUS_CONFIG[manageMission.status].border }}>{STATUS_CONFIG[manageMission.status].label}</span> - {manageMission.laneName ? ( - <span - className="inline-flex items-center gap-1" - style={baseLaneColor ? { color: baseLaneColor } : undefined} - > - BASE - {baseLaneColor ? <LaneAccentDot lane={{ color: baseLaneColor }} size={6} /> : null} - {manageMission.laneName} - </span> - ) : null} - </div> - <div className="mt-4 space-y-3"> - {TERMINAL_MISSION_STATUSES.has(manageMission.status) ? ( - <> - <div className="p-3 text-[12px]" style={{ background: COLORS.pageBg, border: `1px solid ${COLORS.border}`, color: COLORS.textSecondary }}>Archiving removes this mission from the Missions UI.</div> - <label className="flex items-start gap-3 p-3 text-[12px]" style={{ background: COLORS.pageBg, border: `1px solid ${COLORS.border}`, color: COLORS.textPrimary }}> - <input type="checkbox" checked={manageMissionCleanupLanes} onChange={(e) => setManageMissionCleanupLanes(e.target.checked)} disabled={manageMissionBusy} /> - <div><div className="font-medium">Also archive lanes created by this mission</div><div className="mt-1 text-[11px]" style={{ color: COLORS.textMuted }}>ADE will archive every managed lane it can find across this mission's runs.</div></div> - </label> - </> - ) : ( - <div className="p-3 text-[12px]" style={{ background: COLORS.pageBg, border: `1px solid ${COLORS.border}`, color: COLORS.textSecondary }}> - {manageMission.status === "intervention_required" - ? "This mission is waiting for intervention. You can pause, resume, or cancel the current run from here." - : "This mission is currently active. You can pause, resume, or cancel the current run from here."} - </div> - )} - {manageMissionError ? <div className="p-3 text-[11px]" style={{ background: "color-mix(in srgb, var(--color-error) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--color-error) 35%, transparent)", color: COLORS.danger }}>{manageMissionError}</div> : null} - </div> - <div className="mt-4 flex items-center justify-end gap-2"> - <button type="button" style={outlineButton()} onClick={() => closeDialog(false)} disabled={manageMissionBusy}>CLOSE</button> - {TERMINAL_MISSION_STATUSES.has(manageMission.status) ? ( - <button type="button" style={dangerButton()} onClick={() => void handleArchive()} disabled={manageMissionBusy}>{manageMissionBusy ? <SpinnerGap className="h-3 w-3 animate-spin" /> : <Trash className="h-3 w-3" />}ARCHIVE MISSION</button> - ) : ( - <> - <button type="button" style={outlineButton()} onClick={() => void handleResume()} disabled={manageMissionBusy}> - {manageMissionBusy ? <SpinnerGap className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />} - RESUME RUN - </button> - <button type="button" style={outlineButton()} onClick={() => void handlePause()} disabled={manageMissionBusy}> - {manageMissionBusy ? <SpinnerGap className="h-3 w-3 animate-spin" /> : <Pause className="h-3 w-3" />} - PAUSE RUN - </button> - <button type="button" style={dangerButton()} onClick={() => void handleCancel()} disabled={manageMissionBusy}>{manageMissionBusy ? <SpinnerGap className="h-3 w-3 animate-spin" /> : <Stop className="h-3 w-3" />}CANCEL MISSION</button> - </> - )} - </div> - </motion.div> - </> - ) : null} - </AnimatePresence> - ); -} - -/* ════════════════════ CONTEXT MENU ════════════════════ */ - -export function MissionContextMenu() { - const missionContextMenu = useMissionsStore((s) => s.missionContextMenu); - const setMissionContextMenu = useMissionsStore((s) => s.setMissionContextMenu); - const setSelectedMissionId = useMissionsStore((s) => s.setSelectedMissionId); - const setManageMission = useMissionsStore((s) => s.setManageMission); - const setManageMissionCleanupLanes = useMissionsStore((s) => s.setManageMissionCleanupLanes); - const setManageMissionError = useMissionsStore((s) => s.setManageMissionError); - const setManageMissionOpen = useMissionsStore((s) => s.setManageMissionOpen); - - useEffect(() => { - if (!missionContextMenu) return; - const handleClose = () => setMissionContextMenu(null); - document.addEventListener("mousedown", handleClose); - return () => document.removeEventListener("mousedown", handleClose); - }, [missionContextMenu, setMissionContextMenu]); - - const openManageMissionDialog = (mission: MissionSummary) => { - setMissionContextMenu(null); - setManageMission(mission); - setManageMissionCleanupLanes(false); - setManageMissionError(null); - setManageMissionOpen(true); - }; - - if (!missionContextMenu) return null; - return ( - <> - <div className="fixed inset-0 z-40" onClick={() => setMissionContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setMissionContextMenu(null); }} /> - <div className="fixed z-50 min-w-[180px] py-1" style={{ left: missionContextMenu.x, top: missionContextMenu.y, background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, boxShadow: "0 12px 40px rgba(15, 23, 42, 0.28)" }} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> - <button className="flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] transition-colors" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }} onClick={() => { setSelectedMissionId(missionContextMenu.mission.id); setMissionContextMenu(null); }}>Open Mission</button> - <button className="flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] transition-colors" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }} onClick={() => openManageMissionDialog(missionContextMenu.mission)}>Manage Mission</button> - </div> - </> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/ManualInputResponseModal.tsx b/apps/desktop/src/renderer/components/missions/ManualInputResponseModal.tsx deleted file mode 100644 index 9240760c0..000000000 --- a/apps/desktop/src/renderer/components/missions/ManualInputResponseModal.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React, { useCallback, useMemo, useState } from "react"; -import { ChatCircle, Warning, X } from "@phosphor-icons/react"; -import type { MissionIntervention, MissionInterventionResolutionKind } from "../../../shared/types"; -import { - COLORS, - LABEL_STYLE, - MONO_FONT, - SANS_FONT, - outlineButton, - primaryButton, -} from "../lanes/laneDesignTokens"; -import { getMissionInterventionOwnerLabel } from "./missionHelpers"; - -export type ManualInputResponseModalProps = { - intervention: MissionIntervention; - onClose: () => void; - onSubmit: (answer: string, resolutionKind?: MissionInterventionResolutionKind) => Promise<void>; -}; - -export function ManualInputResponseModal({ - intervention, - onClose, - onSubmit, -}: ManualInputResponseModalProps) { - const [answer, setAnswer] = useState(""); - const [submitting, setSubmitting] = useState(false); - - const metadata = intervention.metadata ?? null; - const canProceedWithoutAnswer = metadata?.canProceedWithoutAnswer === true; - const workerDeliveryFailure = metadata?.workerDeliveryFailure === true; - const ownerLabel = getMissionInterventionOwnerLabel(intervention); - const phaseLabel = typeof metadata?.phaseName === "string" && metadata.phaseName.trim().length > 0 - ? metadata.phaseName.trim() - : typeof metadata?.phase === "string" && metadata.phase.trim().length > 0 - ? metadata.phase.trim() - : null; - const urgencyLabel = typeof metadata?.urgency === "string" && metadata.urgency.trim().length > 0 - ? metadata.urgency.trim().toUpperCase() - : null; - - const statusTone = useMemo(() => { - if (workerDeliveryFailure) { - return { - border: "color-mix(in srgb, var(--color-warning) 35%, transparent)", - background: "color-mix(in srgb, var(--color-warning) 12%, transparent)", - color: COLORS.warning, - copy: "A worker message could not be delivered live. ADE kept the note on the worker thread and is asking how you want the mission to recover.", - }; - } - if (canProceedWithoutAnswer) { - return { - border: "color-mix(in srgb, var(--color-accent) 35%, transparent)", - background: "color-mix(in srgb, var(--color-accent) 12%, transparent)", - color: COLORS.accent, - copy: "Optional question. The mission can continue, but your answer will tighten the result.", - }; - } - return { - border: "color-mix(in srgb, var(--color-warning) 35%, transparent)", - background: "color-mix(in srgb, var(--color-warning) 12%, transparent)", - color: COLORS.warning, - copy: `${ownerLabel ?? "ADE"} is waiting on this answer before it should keep going.`, - }; - }, [canProceedWithoutAnswer, ownerLabel, workerDeliveryFailure]); - - const handleSubmit = useCallback(async () => { - const trimmed = answer.trim(); - if (!trimmed.length) return; - setSubmitting(true); - try { - await onSubmit(trimmed, "answer_provided"); - } finally { - setSubmitting(false); - } - }, [answer, onSubmit]); - - const handleSemanticAction = useCallback(async (resolutionKind: MissionInterventionResolutionKind, answerText: string) => { - setSubmitting(true); - try { - await onSubmit(answerText, resolutionKind); - } finally { - setSubmitting(false); - } - }, [onSubmit]); - - return ( - <div - style={{ - position: "fixed", - inset: 0, - zIndex: 9999, - display: "flex", - alignItems: "center", - justifyContent: "center", - background: "rgba(0, 0, 0, 0.7)", - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose(); - }} - > - <div - style={{ - background: COLORS.pageBg, - border: `1px solid ${COLORS.border}`, - width: "100%", - maxWidth: 640, - maxHeight: "85vh", - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} - > - <div - style={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "12px 16px", - borderBottom: `1px solid ${COLORS.border}`, - background: COLORS.cardBg, - }} - > - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - <ChatCircle weight="bold" style={{ color: COLORS.accent, width: 16, height: 16 }} /> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 11, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "1px", - color: COLORS.accent, - }} - > - {workerDeliveryFailure ? "Worker Message Recovery" : ownerLabel ?? "Coordinator Input"} - </span> - </div> - <button - onClick={onClose} - style={{ color: COLORS.textMuted, background: "none", border: "none", cursor: "pointer", padding: 0 }} - > - <X style={{ width: 14, height: 14 }} /> - </button> - </div> - - <div style={{ padding: 16, overflow: "auto", display: "flex", flexDirection: "column", gap: 12 }}> - <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}> - {ownerLabel ? ( - <span style={{ ...LABEL_STYLE, color: COLORS.accent }}> - OWNER: {ownerLabel.toUpperCase()} - </span> - ) : null} - {phaseLabel ? ( - <span style={{ ...LABEL_STYLE, color: COLORS.textDim }}> - PHASE: {phaseLabel.toUpperCase()} - </span> - ) : null} - {urgencyLabel ? ( - <span style={{ ...LABEL_STYLE, color: COLORS.textDim }}> - URGENCY: {urgencyLabel} - </span> - ) : null} - </div> - - <div - style={{ - border: `1px solid ${statusTone.border}`, - background: statusTone.background, - color: statusTone.color, - padding: "10px 12px", - display: "flex", - alignItems: "flex-start", - gap: 8, - }} - > - <Warning weight="fill" style={{ width: 14, height: 14, marginTop: 1, flexShrink: 0 }} /> - <div style={{ fontFamily: SANS_FONT, fontSize: 12, lineHeight: 1.5 }}> - {statusTone.copy} - </div> - </div> - - <div> - <div style={{ ...LABEL_STYLE, color: COLORS.textMuted, marginBottom: 6 }}> - REQUEST - </div> - <div style={{ fontFamily: SANS_FONT, fontSize: 14, color: COLORS.textPrimary, lineHeight: 1.6 }}> - {intervention.title} - </div> - </div> - - <div> - <div style={{ ...LABEL_STYLE, color: COLORS.textMuted, marginBottom: 6 }}> - DETAILS - </div> - <div - style={{ - fontFamily: MONO_FONT, - fontSize: 12, - color: COLORS.textSecondary, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.border}`, - padding: "12px 14px", - lineHeight: 1.6, - }} - > - {intervention.body} - </div> - </div> - - {intervention.requestedAction ? ( - <div> - <div style={{ ...LABEL_STYLE, color: COLORS.textMuted, marginBottom: 6 }}> - {workerDeliveryFailure ? "RECOVERY GUIDANCE" : "WHAT TO DO"} - </div> - <div style={{ fontFamily: SANS_FONT, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.6 }}> - {intervention.requestedAction} - </div> - </div> - ) : null} - - <div> - <div style={{ ...LABEL_STYLE, color: COLORS.textMuted, marginBottom: 6 }}> - {workerDeliveryFailure ? "HOW ADE SHOULD RECOVER" : "YOUR ANSWER"} - </div> - <textarea - value={answer} - onChange={(event) => setAnswer(event.target.value)} - placeholder={workerDeliveryFailure - ? "Tell ADE how to recover: reroute through the coordinator, leave the note queued, or retry when the worker is active again." - : "Type the answer you want the mission to follow..."} - style={{ - width: "100%", - minHeight: 140, - resize: "vertical", - padding: "12px 14px", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - fontSize: 12, - lineHeight: 1.6, - outline: "none", - }} - /> - </div> - </div> - - <div - style={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - padding: "12px 16px", - borderTop: `1px solid ${COLORS.border}`, - background: COLORS.cardBg, - }} - > - <div style={{ fontFamily: SANS_FONT, fontSize: 12, color: COLORS.textMuted }}> - {workerDeliveryFailure - ? "Send recovery guidance only if you want ADE to retry or redirect the stale worker message." - : canProceedWithoutAnswer - ? "You can close this if you want the mission to keep its current assumptions." - : "Send an answer to unblock the mission cleanly."} - </div> - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - <button style={outlineButton()} onClick={onClose} disabled={submitting}> - CLOSE - </button> - {canProceedWithoutAnswer ? ( - <button - style={outlineButton()} - onClick={() => void handleSemanticAction("accept_defaults", "Accept the default assumptions and continue.")} - disabled={submitting} - > - ACCEPT DEFAULTS - </button> - ) : null} - <button - style={outlineButton()} - onClick={() => void handleSemanticAction("skip_question", "Skip this question and continue with best-effort assumptions.")} - disabled={submitting} - > - SKIP QUESTION - </button> - <button - style={outlineButton({ borderColor: "color-mix(in srgb, var(--color-warning) 40%, transparent)", color: COLORS.warning })} - onClick={() => void handleSemanticAction("cancel_run", "Cancel the run.")} - disabled={submitting} - > - CANCEL RUN - </button> - <button - style={primaryButton()} - onClick={() => void handleSubmit()} - disabled={submitting || !answer.trim()} - > - {submitting ? "SENDING..." : "SEND ANSWER"} - </button> - </div> - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionActiveContext.tsx b/apps/desktop/src/renderer/components/missions/MissionActiveContext.tsx deleted file mode 100644 index bd991144d..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionActiveContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { createContext, useContext } from "react"; - -const MissionActiveContext = createContext(true); - -export function MissionActiveProvider({ - active, - children, -}: { - active: boolean; - children: React.ReactNode; -}) { - return ( - <MissionActiveContext.Provider value={active}> - {children} - </MissionActiveContext.Provider> - ); -} - -export function useMissionPageActive(): boolean { - return useContext(MissionActiveContext); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionActivePhasePanel.tsx b/apps/desktop/src/renderer/components/missions/MissionActivePhasePanel.tsx deleted file mode 100644 index d2760dbaf..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionActivePhasePanel.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import React, { useState } from "react"; -import { - CaretDown, - CaretRight, - WarningCircle, - CheckCircle, - Eye, -} from "@phosphor-icons/react"; -import type { ActivePhaseViewModel } from "./missionControlViewModel"; -import type { OrchestratorPromptInspector, PhaseCard } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; -import { PromptInspectorCard } from "./PromptInspectorCard"; - -/* ─── Phase Stepper ─── */ - -function PhaseStepper({ - phases, - activePhaseKey, - compact = false, -}: { - phases: PhaseCard[]; - activePhaseKey: string | null; - compact?: boolean; -}) { - const sorted = phases.slice().sort((a, b) => a.position - b.position); - const activeIdx = sorted.findIndex((p) => p.phaseKey === activePhaseKey); - const dotSize = compact ? 14 : 16; - const gapClass = compact ? "gap-1" : "gap-1.5"; - const labelClass = compact ? "whitespace-nowrap text-[10px]" : "whitespace-nowrap text-[11px]"; - const lineClass = compact ? "mx-1.5 flex-1" : "mx-2 flex-1"; - - return ( - <div className="flex items-center gap-0" style={{ minHeight: compact ? 24 : 28 }}> - {sorted.map((phase, i) => { - const isCurrent = i === activeIdx; - const isDone = activeIdx >= 0 && i < activeIdx; - const isLast = i === sorted.length - 1; - - const dotColor = isCurrent ? COLORS.accent : isDone ? COLORS.success : COLORS.textDim; - const labelColor = isCurrent ? COLORS.textPrimary : isDone ? COLORS.textSecondary : COLORS.textDim; - - return ( - <React.Fragment key={phase.id}> - <div className={`flex items-center ${gapClass}`}> - {isDone ? ( - <CheckCircle weight="fill" style={{ color: COLORS.success, width: dotSize, height: dotSize, flexShrink: 0 }} /> - ) : ( - <div - style={{ - width: dotSize, - height: dotSize, - borderRadius: "50%", - border: `2px solid ${dotColor}`, - background: isCurrent ? COLORS.accent : "transparent", - flexShrink: 0, - }} - /> - )} - <span - className={labelClass} - style={{ - color: labelColor, - fontFamily: SANS_FONT, - fontWeight: isCurrent ? 600 : 400, - }} - > - {phase.name} - </span> - </div> - {!isLast && ( - <div - className={lineClass} - style={{ - height: 1, - minWidth: 16, - background: isDone ? "color-mix(in srgb, var(--color-success) 50%, transparent)" : COLORS.border, - }} - /> - )} - </React.Fragment> - ); - })} - </div> - ); -} - -/* ─── Main Panel ─── */ - -export function MissionActivePhasePanel({ - activePhase, - allPhases, - promptInspector, - promptLoading, - promptError, - onInspectPrompt, - coordinatorAvailability, - compact = false, -}: { - activePhase: ActivePhaseViewModel | null; - allPhases?: PhaseCard[] | null; - promptInspector: OrchestratorPromptInspector | null; - promptLoading?: boolean; - promptError?: string | null; - onInspectPrompt?: () => void; - coordinatorAvailability?: { available: boolean; mode: string; summary: string | null } | null; - compact?: boolean; -}) { - const [showDetails, setShowDetails] = useState(false); - const [showPrompt, setShowPrompt] = useState(false); - - if (!activePhase) return null; - - const phases = allPhases ?? []; - const actionItems = [ - ...activePhase.exitRequirements, - ...activePhase.capabilityWarnings, - ]; - const hasActions = actionItems.length > 0; - const toneTextSize = compact ? "text-[10px]" : "text-[11px]"; - - return ( - <div className={compact ? "mx-3 mt-2 space-y-1.5" : "mx-4 mt-3 space-y-2"}> - {/* Phase stepper */} - {phases.length > 1 && ( - <PhaseStepper phases={phases} activePhaseKey={activePhase.phase?.phaseKey ?? null} compact={compact} /> - )} - - {/* Action items — only if something needs attention */} - {hasActions && ( - <div - className={compact ? "flex items-start gap-2 px-2.5 py-1.5" : "flex items-start gap-2 px-3 py-2"} - style={{ - background: "color-mix(in srgb, var(--color-warning) 8%, transparent)", - borderLeft: `3px solid ${COLORS.warning}`, - }} - > - <WarningCircle weight="bold" style={{ color: COLORS.warning, width: 14, height: 14, marginTop: 1, flexShrink: 0 }} /> - <div className="min-w-0 space-y-0.5"> - {actionItems.map((item, i) => ( - <div key={i} className={toneTextSize} style={{ color: COLORS.textSecondary }}> - {item} - </div> - ))} - </div> - </div> - )} - - {/* Expandable details toggle */} - <div className={compact ? "flex items-center gap-2" : "flex items-center gap-3"}> - <button - type="button" - onClick={() => setShowDetails((prev) => !prev)} - className="flex items-center gap-1 text-[10px]" - style={{ color: COLORS.textDim, fontFamily: MONO_FONT, background: "none", border: "none", cursor: "pointer", padding: 0 }} - > - {showDetails ? <CaretDown weight="bold" className="h-2.5 w-2.5" /> : <CaretRight weight="bold" className="h-2.5 w-2.5" />} - Phase details - </button> - - <button - type="button" - className="flex items-center gap-1 text-[10px]" - style={{ color: COLORS.textDim, fontFamily: MONO_FONT, background: "none", border: "none", cursor: "pointer", padding: 0 }} - onClick={() => { - setShowPrompt((prev) => !prev); - if (!showPrompt) onInspectPrompt?.(); - }} - > - <Eye weight="bold" className="h-2.5 w-2.5" /> - {showPrompt ? "Hide prompt" : compact ? "Prompt" : "View effective prompt"} - </button> - </div> - - {/* Expanded details */} - {showDetails && ( - <div - className={compact ? "space-y-2 px-2.5 py-2" : "space-y-2 px-3 py-2"} - style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }} - > - <div className="grid grid-cols-2 gap-x-6 gap-y-1 text-[10px]" style={{ color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - {activePhase.phase?.model?.modelId && ( - <> - <span style={{ color: COLORS.textDim }}>Model</span> - <span>{activePhase.phase.model.modelId}</span> - </> - )} - <span style={{ color: COLORS.textDim }}>Mode</span> - <span>{activePhase.modeLabel}</span> - <span style={{ color: COLORS.textDim }}>Ask Questions</span> - <span>{activePhase.clarificationLabel}</span> - </div> - - {activePhase.blockedLaterWork.length > 0 && ( - <div className="mt-1"> - <div className="text-[10px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}>Blocked downstream</div> - {activePhase.blockedLaterWork.map((entry, i) => ( - <div key={i} className="text-[10px] mt-0.5" style={{ color: COLORS.textSecondary }}>{entry}</div> - ))} - </div> - )} - - {coordinatorAvailability && ( - <div className="text-[10px]" style={{ color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - Coordinator: {coordinatorAvailability.available ? "available" : "offline"} · {coordinatorAvailability.mode.replace(/_/g, " ")} - {coordinatorAvailability.summary ? ` — ${coordinatorAvailability.summary}` : ""} - </div> - )} - - {activePhase.phase?.instructions?.trim() && ( - <details className="mt-1"> - <summary className="text-[10px] cursor-pointer" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Custom instructions - </summary> - <pre - className="mt-1 whitespace-pre-wrap break-words text-[10px]" - style={{ color: COLORS.textDim, fontFamily: MONO_FONT }} - > - {activePhase.phase.instructions} - </pre> - </details> - )} - </div> - )} - - {/* Prompt inspector */} - {showPrompt && ( - <PromptInspectorCard - inspector={promptInspector} - loading={promptLoading} - error={promptError} - title="Effective planning / coordinator prompt" - /> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx b/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx deleted file mode 100644 index 74ce5aee7..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionArtifactsTab.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import React, { useMemo, useState } from "react"; -import type { GroupedMissionArtifacts, MissionArtifactRecord } from "./missionControlViewModel"; -import type { ComputerUseOwnerSnapshot, MissionCloseoutRequirement } from "../../../shared/types"; -import { COLORS, MONO_FONT, outlineButton } from "../lanes/laneDesignTokens"; -import { MissionComputerUsePanel } from "./MissionComputerUsePanel"; - -type ArtifactGroupMode = "phase" | "step" | "worker" | "type"; - -const ARTIFACT_GROUP_MODES: readonly ArtifactGroupMode[] = ["phase", "step", "worker", "type"]; - -function isExternalUri(value: string): boolean { - return /^https?:\/\//i.test(value); -} - -function sectionHeading(label: string) { - return ( - <div className="text-[9px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {label} - </div> - ); -} - -function describeCloseoutRequirement(requirement: MissionCloseoutRequirement): string { - const purposes: Partial<Record<MissionCloseoutRequirement["key"], string>> = { - validation_verdict: "Shows whether ADE finished validation with an explicit pass or fail call.", - changed_files_summary: "Summarizes which files changed so the closeout explains the implementation scope.", - final_outcome_summary: "Captures the end result in plain English for the operator.", - review_summary: "Summarizes the review findings, follow-ups, or residual risk.", - screenshot: "Captures a visual proof artifact for UI-facing work.", - browser_verification: "Records browser-based proof that the user flow was actually exercised.", - browser_trace: "Keeps a browser trace for debugging or audit follow-up.", - test_report: "Shows what ADE ran to validate the work and what happened.", - pr_url: "Links the mission to an external pull request if one is created after closeout.", - proposal_url: "Links the mission to the proposal or review thread created during finalization.", - }; - return requirement.detail?.trim() || purposes[requirement.key] || "Required to explain or verify the mission before closeout."; -} - -function friendlyRequirementStatus(requirement: MissionCloseoutRequirement): { - label: "Captured" | "Incomplete" | "Missing" | "Blocked" | "Not required"; - color: string; -} { - if (!requirement.required || requirement.status === "waived") { - return { label: "Not required", color: COLORS.textMuted }; - } - if (requirement.status === "present") { - return { label: "Captured", color: COLORS.success }; - } - if (requirement.status === "incomplete") { - return { label: "Incomplete", color: COLORS.warning }; - } - if (requirement.status === "blocked_by_capability") { - return { label: "Blocked", color: COLORS.danger }; - } - return { label: "Missing", color: COLORS.warning }; -} - -function ArtifactPreview({ artifact }: { artifact: MissionArtifactRecord | null }) { - if (!artifact) { - return ( - <div className="p-4 text-[11px]" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, color: COLORS.textMuted }}> - Select an artifact to inspect its evidence, linked URI, or extracted text content. - </div> - ); - } - - return ( - <div className="space-y-3"> - <div className="p-4" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-start justify-between gap-3"> - <div> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {artifact.source} - </div> - <div className="mt-2 text-[14px] font-semibold" style={{ color: COLORS.textPrimary }}> - {artifact.title} - </div> - {artifact.description ? ( - <div className="mt-2 text-[11px]" style={{ color: COLORS.textSecondary }}> - {artifact.description} - </div> - ) : null} - </div> - {artifact.uri ? ( - <button - type="button" - style={outlineButton({ height: 28, padding: "0 10px", fontSize: 9 })} - onClick={() => { - if (isExternalUri(artifact.uri!)) void window.ade.app.openExternal(artifact.uri!); - else void window.ade.app.revealPath(artifact.uri!); - }} - > - {isExternalUri(artifact.uri) ? "OPEN URI" : "REVEAL"} - </button> - ) : null} - </div> - <div className="mt-3 flex flex-wrap items-center gap-2 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>{artifact.artifactType}</span> - {artifact.phaseName ? <span>{artifact.phaseName}</span> : null} - {artifact.stepTitle ? <span>{artifact.stepTitle}</span> : null} - {artifact.workerLabel ? <span>{artifact.workerLabel}</span> : null} - {artifact.declared ? <span>declared</span> : <span>discovered</span>} - {artifact.missingExpectedEvidence ? <span style={{ color: COLORS.warning }}>missing</span> : null} - </div> - </div> - - {artifact.textContent ? ( - <pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words p-4 text-[11px]" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - {artifact.textContent} - </pre> - ) : ( - <div className="p-4 text-[11px]" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, color: COLORS.textMuted }}> - This artifact is URI-backed only. Use the action button above to open it. - </div> - )} - </div> - ); -} - -export function MissionArtifactsTab({ - groupedArtifacts, - closeoutRequirements = [], - missionId = null, - laneId = null, - computerUseSnapshot = null, -}: { - groupedArtifacts: GroupedMissionArtifacts; - closeoutRequirements?: MissionCloseoutRequirement[]; - missionId?: string | null; - runId?: string | null; - laneId?: string | null; - computerUseSnapshot?: ComputerUseOwnerSnapshot | null; -}) { - const [groupMode, setGroupMode] = useState<ArtifactGroupMode>("phase"); - const [selectedArtifactId, setSelectedArtifactId] = useState<string | null>(null); - const [showOptionalRequirements, setShowOptionalRequirements] = useState(false); - - const groups = useMemo(() => { - if (groupMode === "step") return groupedArtifacts.byStep; - if (groupMode === "worker") return groupedArtifacts.byWorker; - if (groupMode === "type") return groupedArtifacts.byType; - return groupedArtifacts.byPhase; - }, [groupMode, groupedArtifacts.byPhase, groupedArtifacts.byStep, groupedArtifacts.byType, groupedArtifacts.byWorker]); - - const selectedArtifact = groupedArtifacts.all.find((artifact) => artifact.id === selectedArtifactId) - ?? groupedArtifacts.all[0] - ?? null; - const visibleRequirements = useMemo( - () => closeoutRequirements.filter((requirement) => { - if (showOptionalRequirements) return true; - return requirement.required && requirement.status !== "waived"; - }), - [closeoutRequirements, showOptionalRequirements], - ); - const hiddenRequirementCount = Math.max(0, closeoutRequirements.length - visibleRequirements.length); - - return ( - <div className="flex h-full min-h-0 flex-col gap-3"> - {missionId && computerUseSnapshot ? ( - <MissionComputerUsePanel missionId={missionId} laneId={laneId} initialSnapshot={computerUseSnapshot} /> - ) : null} - <div className="flex min-h-0 flex-1 flex-col gap-3 lg:flex-row"> - <div className="min-h-0 min-w-0 lg:w-[380px] lg:max-w-[40%] lg:shrink-0"> - <div className="space-y-3"> - <div className="flex items-center gap-2"> - {ARTIFACT_GROUP_MODES.map((mode) => ( - <button - key={mode} - type="button" - style={outlineButton({ - height: 24, - padding: "0 8px", - fontSize: 9, - background: groupMode === mode ? "color-mix(in srgb, var(--color-accent) 14%, transparent)" : COLORS.cardBg, - color: groupMode === mode ? COLORS.accent : COLORS.textMuted, - border: `1px solid ${groupMode === mode ? "color-mix(in srgb, var(--color-accent) 35%, transparent)" : COLORS.border}`, - })} - onClick={() => setGroupMode(mode)} - aria-pressed={groupMode === mode} - > - {mode.toUpperCase()} - </button> - ))} - </div> - - {groupedArtifacts.expectedEvidence.length > 0 ? ( - <div className="rounded-sm p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[9px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Expected evidence - </div> - <div className="mt-2 flex flex-wrap gap-2"> - {groupedArtifacts.expectedEvidence.map((entry) => ( - <span - key={entry} - className="px-1.5 py-0.5 text-[9px] uppercase tracking-[1px]" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, color: COLORS.textSecondary, fontFamily: MONO_FONT }} - > - {entry.replace(/_/g, " ")} - </span> - ))} - </div> - </div> - ) : null} - - {closeoutRequirements.length > 0 ? ( - <div className="rounded-sm p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between gap-3"> - <div className="text-[9px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Closeout status - </div> - {hiddenRequirementCount > 0 ? ( - <button - type="button" - style={outlineButton({ height: 24, padding: "0 8px", fontSize: 9 })} - onClick={() => setShowOptionalRequirements((current) => !current)} - > - {showOptionalRequirements ? "HIDE OPTIONAL" : `SHOW ${hiddenRequirementCount} OPTIONAL`} - </button> - ) : null} - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textSecondary }}> - These checks explain what evidence ADE still needs before closeout is complete. - </div> - <div className="mt-2 space-y-2"> - {visibleRequirements.length === 0 ? ( - <div className="rounded-sm p-2 text-[10px]" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, color: COLORS.textSecondary }}> - Only optional or waived closeout checks remain. Use the toggle above if you want to inspect them. - </div> - ) : visibleRequirements.map((requirement) => { - const status = friendlyRequirementStatus(requirement); - return ( - <div key={requirement.key} className="rounded-sm p-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between gap-2"> - <div className="text-[10px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}> - {requirement.label} - </div> - <div className="text-[9px] uppercase" style={{ color: status.color, fontFamily: MONO_FONT }}> - {status.label} - </div> - </div> - <div className="mt-1 text-[10px]" style={{ color: COLORS.textSecondary }}> - {describeCloseoutRequirement(requirement)} - </div> - </div> - );})} - </div> - </div> - ) : null} - - <div className="space-y-3 overflow-auto"> - {groupedArtifacts.all.length === 0 ? ( - <div className="rounded-sm p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - {sectionHeading("Artifacts")} - <div className="mt-2 text-[11px]" style={{ color: COLORS.textSecondary }}> - No artifacts have been attached yet. Expected evidence slots will appear here as the mission produces outputs. - </div> - </div> - ) : ( - groups.map((group) => ( - <section key={group.key} className="rounded-sm p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between"> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}> - {group.label} - </div> - <div className="text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {group.items.length} - </div> - </div> - <div className="mt-2 space-y-2"> - {group.items.map((artifact) => { - const selected = selectedArtifact?.id === artifact.id; - return ( - <button - key={artifact.id} - type="button" - className="w-full rounded-sm px-3 py-2 text-left" - style={{ - background: selected ? "color-mix(in srgb, var(--color-accent) 12%, transparent)" : COLORS.recessedBg, - border: `1px solid ${selected ? "color-mix(in srgb, var(--color-accent) 35%, transparent)" : COLORS.border}`, - }} - onClick={() => setSelectedArtifactId(artifact.id)} - > - <div className="flex items-center justify-between gap-2"> - <div className="min-w-0 text-[11px] font-medium" style={{ color: COLORS.textPrimary }}> - {artifact.title} - </div> - {artifact.missingExpectedEvidence ? ( - <span className="text-[9px]" style={{ color: COLORS.warning, fontFamily: MONO_FONT }}> - missing - </span> - ) : null} - </div> - <div className="mt-1 flex flex-wrap items-center gap-2 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>{artifact.artifactType}</span> - {artifact.stepTitle ? <span>{artifact.stepTitle}</span> : null} - {artifact.uri ? <span>uri</span> : null} - {artifact.textContent ? <span>preview</span> : null} - </div> - </button> - ); - })} - </div> - </section> - )) - )} - </div> - </div> - </div> - - <div className="min-h-0 min-w-0 flex-1 overflow-auto"> - {groupedArtifacts.all.length > 0 ? ( - <ArtifactPreview artifact={selectedArtifact} /> - ) : ( - <div className="p-4 text-[11px]" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, color: COLORS.textMuted }}> - Artifact previews will appear here once the mission records files, URIs, or extracted evidence. - </div> - )} - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx b/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx deleted file mode 100644 index 5cf012335..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionChatV2.tsx +++ /dev/null @@ -1,701 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { - type MissionIntervention, - type OrchestratorChatThread, - type OrchestratorChatMessage, - type OrchestratorWorkerState, - type OrchestratorChatTarget, - type MissionStatus, - type MissionRunView, - type OrchestratorRunStatus, -} from "../../../shared/types"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; -import { useMissionPolling } from "./useMissionPolling"; -import { formatMissionWorkerPresentation } from "./missionHelpers"; -import { buildMissionStateNarrative, prepareMissionFeedItems } from "./missionFeedPresentation"; -import { - readRecord, - statusDotForWorker, -} from "./chatFilters"; -import { ChatChannelList, type Channel } from "./ChatChannelList"; -import { ChatMessageArea } from "./ChatMessageArea"; -import { ChatInput } from "./ChatInput"; -import { ChatSurfaceShell } from "../chat/ChatSurfaceShell"; -import { buildChatAppearanceRootStyle } from "../chat/chatAppearance"; -import { useAppStore } from "../../state/appStore"; -import { useMissionsStoreApi } from "./useMissionsStore"; -import { - resolveChatTargetChannelId, - resolveWorkerThreadChannelStatus, - shouldShowWorkerStreamingIndicator, -} from "./missionChatChannelModel"; -import { useMissionPageActive } from "./MissionActiveContext"; - -const BG_PAGE = COLORS.pageBg; -const THREAD_MESSAGE_PAGE_SIZE = 100; - -function resolveMissionPhaseAccent(phaseLabel: string | null): string { - const normalized = (phaseLabel ?? "").trim().toLowerCase(); - if (!normalized.length) return "#38BDF8"; - if (/(plan|discover|research|scop|shape|intake)/.test(normalized)) return "#38BDF8"; - if (/(build|implement|craft|develop|ship)/.test(normalized)) return "#A78BFA"; - if (/(validat|test|qa|review|check)/.test(normalized)) return "#F59E0B"; - if (/(merge|release|launch|handoff|finish|done)/.test(normalized)) return "#22C55E"; - return "#38BDF8"; -} - -function resolveMissionSurfaceAccent(channel: Channel | undefined): string { - switch (channel?.kind) { - case "global": - return "#22C55E"; - case "orchestrator": - return "#60A5FA"; - case "teammate": - return "#06B6D4"; - case "worker": - return resolveMissionPhaseAccent(channel.phaseLabel); - default: - return "#38BDF8"; - } -} - -function formatMissionFeedMessageContent(title: string, detail: string): string { - const trimmedTitle = title.trim(); - const trimmedDetail = detail.trim(); - if (!trimmedTitle.length) return trimmedDetail; - if (!trimmedDetail.length) return `**${trimmedTitle}**`; - return `**${trimmedTitle}**\n\n${trimmedDetail}`; -} - -function workerThreadStatus( - threadStatus: OrchestratorChatThread["status"], - workerState: OrchestratorWorkerState | undefined, - runStatus: OrchestratorRunStatus | null, -): Channel["status"] { - return resolveWorkerThreadChannelStatus({ threadStatus, workerState, runStatus }); -} - -function orchestratorThreadStatus( - threadStatus: OrchestratorChatThread["status"], - runStatus: OrchestratorRunStatus | null, -): Channel["status"] { - if (runStatus === "succeeded" || runStatus === "failed" || runStatus === "canceled") return "closed"; - return threadStatus; -} - -function findThreadIntervention(args: { - interventions: MissionIntervention[]; - selectedChannel: Channel | undefined; - runId: string | null; -}): MissionIntervention | null { - const { interventions, selectedChannel, runId } = args; - if (!selectedChannel || selectedChannel.kind === "global") return null; - const openInterventions = interventions - .filter((intervention) => intervention.status === "open") - .slice() - .sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt)); - - return openInterventions.find((intervention) => { - const metadata = readRecord(intervention.metadata); - const interventionRunId = typeof metadata?.runId === "string" ? metadata.runId.trim() : ""; - if (interventionRunId.length > 0 && runId && interventionRunId !== runId) return false; - const attemptId = typeof metadata?.attemptId === "string" ? metadata.attemptId.trim() : ""; - const stepId = typeof metadata?.stepId === "string" ? metadata.stepId.trim() : ""; - const stepKey = typeof metadata?.stepKey === "string" ? metadata.stepKey.trim() : ""; - const reasonCode = typeof metadata?.reasonCode === "string" ? metadata.reasonCode.trim() : ""; - - if (selectedChannel.kind === "orchestrator") { - return reasonCode === "coordinator_unavailable" - || reasonCode === "coordinator_recovery_failed" - || (!attemptId && !stepId && !stepKey); - } - - if (selectedChannel.kind === "worker") { - return ( - (attemptId.length > 0 && attemptId === selectedChannel.attemptId) - || (stepKey.length > 0 && stepKey === selectedChannel.stepKey) - ); - } - - return false; - }) ?? null; -} - -type MissionChatV2Props = { - missionId: string; - missionStatus: MissionStatus | null; - runId: string | null; - runStatus: OrchestratorRunStatus | null; - runView?: MissionRunView | null; - interventions: MissionIntervention[]; - jumpTarget: OrchestratorChatTarget | null; - onJumpHandled: () => void; - onOpenIntervention: (interventionId: string) => void; -}; - -export const MissionChatV2 = React.memo(function MissionChatV2({ - missionId, missionStatus, runId, runStatus, runView = null, interventions, jumpTarget, onJumpHandled, onOpenIntervention, -}: MissionChatV2Props) { - const missionsStore = useMissionsStoreApi(); - const active = useMissionPageActive(); - // ── State ── - const [threads, setThreads] = useState<OrchestratorChatThread[]>([]); - const [threadMessages, setThreadMessages] = useState<OrchestratorChatMessage[]>([]); - const [threadMessagesLoading, setThreadMessagesLoading] = useState(false); - const [threadMessagesLoadingMore, setThreadMessagesLoadingMore] = useState(false); - const [threadMessagesError, setThreadMessagesError] = useState<string | null>(null); - const [threadMessagesHasMore, setThreadMessagesHasMore] = useState(false); - const [workerStates, setWorkerStates] = useState<OrchestratorWorkerState[]>([]); - const [selectedChannelId, setSelectedChannelId] = useState("global"); - const [runActionBusy, setRunActionBusy] = useState<"pause" | "resume" | "cancel" | null>(null); - const [completedCollapsed, setCompletedCollapsed] = useState(true); - const [jumpNotice, setJumpNotice] = useState<string | null>(null); - const [input, setInput] = useState(""); - const [sending, setSending] = useState(false); - - const selectedChannelIdRef = useRef("global"); - const threadRefreshTimerRef = useRef<number | null>(null); - const messageRefreshTimerRef = useRef<number | null>(null); - const channelsRef = useRef<Channel[]>([]); - const threadMessagesRef = useRef<OrchestratorChatMessage[]>([]); - const latestThreadMessagesRequestRef = useRef(0); - - useEffect(() => { selectedChannelIdRef.current = selectedChannelId; }, [selectedChannelId]); - useEffect(() => { threadMessagesRef.current = threadMessages; }, [threadMessages]); - const workerStateByAttempt = useMemo(() => { const m = new Map<string, OrchestratorWorkerState>(); for (const s of workerStates) m.set(s.attemptId, s); return m; }, [workerStates]); - - // ── Build channel list from threads ── - const channels = useMemo<Channel[]>(() => { - const result: Channel[] = [{ - id: "global", kind: "global", label: "Mission Feed", fullLabel: "Mission Feed", - threadId: null, sessionId: null, laneId: null, status: "active", stepKey: null, attemptId: null, unreadCount: 0, phaseLabel: null, - }]; - const coordThread = threads.find((t) => t.threadType === "coordinator"); - if (coordThread) result.push({ id: `thread:${coordThread.id}`, kind: "orchestrator", label: "Orchestrator", fullLabel: "Orchestrator", threadId: coordThread.id, sessionId: coordThread.sessionId ?? null, laneId: coordThread.laneId ?? null, status: orchestratorThreadStatus(coordThread.status, runStatus), stepKey: null, attemptId: null, unreadCount: coordThread.unreadCount, phaseLabel: null }); - for (const t of threads.filter((t) => t.threadType === "teammate")) - result.push({ id: `thread:${t.id}`, kind: "teammate", label: t.title || "Teammate", fullLabel: t.title || "Teammate", threadId: t.id, sessionId: t.sessionId ?? null, laneId: t.laneId ?? null, status: t.status, stepKey: t.stepKey ?? null, attemptId: t.attemptId ?? null, unreadCount: t.unreadCount, phaseLabel: null }); - for (const t of threads.filter((t) => t.threadType === "worker")) { - const p = formatMissionWorkerPresentation({ title: t.title, stepKey: t.stepKey ?? null }); - result.push({ id: `thread:${t.id}`, kind: "worker", label: p.label, fullLabel: p.fullLabel, threadId: t.id, sessionId: t.sessionId ?? null, laneId: t.laneId ?? null, status: workerThreadStatus(t.status, t.attemptId ? workerStateByAttempt.get(t.attemptId) : undefined, runStatus), stepKey: t.stepKey ?? null, attemptId: t.attemptId ?? null, unreadCount: t.unreadCount, phaseLabel: p.phaseLabel }); - } - return result; - }, [runStatus, threads, workerStateByAttempt]); - - const teammateChannels = useMemo(() => channels.filter((c) => c.kind === "teammate"), [channels]); - const activeWorkerChannels = useMemo(() => channels.filter((c) => c.kind === "worker" && c.status === "active"), [channels]); - const completedWorkerChannels = useMemo(() => channels.filter((c) => c.kind === "worker" && c.status !== "active"), [channels]); - const orchestratorChannel = useMemo(() => channels.find((c) => c.kind === "orchestrator") ?? null, [channels]); - const selectedChannel = useMemo(() => channels.find((c) => c.id === selectedChannelId) ?? channels[0], [channels, selectedChannelId]); - const missionSurfaceMode = selectedChannel?.kind === "global" ? "mission-feed" : "mission-thread"; - const missionSurfaceAccent = useMemo(() => resolveMissionSurfaceAccent(selectedChannel), [selectedChannel]); - const chatFontSizePx = useAppStore((s) => s.chatFontSizePx); - const chatTranscriptDensity = useAppStore((s) => s.chatTranscriptDensity); - const chatChromeTint = useAppStore((s) => s.chatChromeTint); - const chatShellGeometry = useAppStore((s) => s.chatShellGeometry); - const chatAppearanceRootStyle = useMemo( - () => buildChatAppearanceRootStyle({ chatFontSizePx, transcriptDensity: chatTranscriptDensity }), - [chatFontSizePx, chatTranscriptDensity], - ); - - useEffect(() => { channelsRef.current = channels; }, [channels]); - - const workerStatusDotFn = useCallback((attemptId: string | null) => attemptId ? statusDotForWorker(workerStateByAttempt.get(attemptId)?.state) : COLORS.success, [workerStateByAttempt]); - - // ── Data fetching ── - const refreshThreads = useCallback(async () => { try { setThreads(await window.ade.orchestrator.listChatThreads({ missionId, includeClosed: true })); } catch { /* ignore */ } }, [missionId]); - const refreshThreadMessages = useCallback(async ( - threadId?: string | null, - mode: "replace" | "append-older" = "replace", - ) => { - if (!threadId) { - setThreadMessages([]); - setThreadMessagesError(null); - setThreadMessagesHasMore(false); - setThreadMessagesLoading(false); - setThreadMessagesLoadingMore(false); - return; - } - const requestId = latestThreadMessagesRequestRef.current + 1; - latestThreadMessagesRequestRef.current = requestId; - const before = mode === "append-older" ? threadMessagesRef.current[0]?.timestamp ?? null : null; - if (mode === "replace") { - setThreadMessagesLoading(true); - setThreadMessagesError(null); - setThreadMessagesHasMore(false); - } else { - setThreadMessagesLoadingMore(true); - setThreadMessagesError(null); - } - try { - const nextMessages = await window.ade.orchestrator.getThreadMessages({ - missionId, - threadId, - limit: THREAD_MESSAGE_PAGE_SIZE, - before, - }); - if (latestThreadMessagesRequestRef.current !== requestId) return; - setThreadMessages((current) => { - if (mode === "append-older") { - const seen = new Set(current.map((entry) => entry.id)); - return [...nextMessages.filter((entry) => !seen.has(entry.id)), ...current]; - } - return nextMessages; - }); - setThreadMessagesHasMore(nextMessages.length >= THREAD_MESSAGE_PAGE_SIZE); - setThreadMessagesError(null); - } catch (error) { - if (latestThreadMessagesRequestRef.current !== requestId) return; - setThreadMessagesError(error instanceof Error ? error.message : String(error)); - } finally { - if (latestThreadMessagesRequestRef.current !== requestId) return; - if (mode === "replace") { - setThreadMessagesLoading(false); - } else { - setThreadMessagesLoadingMore(false); - } - } - }, [missionId]); - const refreshWorkers = useCallback(async () => { - try { - setWorkerStates(runId ? await window.ade.orchestrator.getWorkerStates({ runId }) : []); - } catch { - /* ignore */ - } - }, [runId]); - const refreshSelectedMessages = useCallback(async () => { - if (!selectedChannel) return; - if (selectedChannel.kind === "global") return; - await refreshThreadMessages(selectedChannel.threadId); - }, [refreshThreadMessages, selectedChannel]); - const loadOlderSelectedMessages = useCallback(() => { - if ( - !selectedChannel || - selectedChannel.kind === "global" || - !selectedChannel.threadId || - !threadMessagesHasMore || - threadMessagesLoadingMore - ) { - return; - } - void refreshThreadMessages(selectedChannel.threadId, "append-older"); - }, [refreshThreadMessages, selectedChannel, threadMessagesHasMore, threadMessagesLoadingMore]); - const retrySelectedMessages = useCallback(() => { - if (!selectedChannel || selectedChannel.kind === "global" || !selectedChannel.threadId) return; - void refreshThreadMessages(selectedChannel.threadId); - }, [refreshThreadMessages, selectedChannel]); - - useEffect(() => { - latestThreadMessagesRequestRef.current += 1; - setThreads([]); - setThreadMessages([]); - setThreadMessagesLoading(false); - setThreadMessagesLoadingMore(false); - setThreadMessagesError(null); - setThreadMessagesHasMore(false); - setWorkerStates([]); - setSelectedChannelId("global"); - setRunActionBusy(null); - setCompletedCollapsed(true); - setJumpNotice(null); - setInput(""); - setSending(false); - }, [missionId]); - - useEffect(() => { - if (!active) return; - void refreshThreads(); - void refreshWorkers(); - }, [active, refreshThreads, refreshWorkers]); - useEffect(() => { - if (!active) return; - void refreshSelectedMessages(); - }, [active, refreshSelectedMessages]); - useMissionPolling( - useCallback(() => { - void refreshThreads(); - void refreshWorkers(); - }, [refreshThreads, refreshWorkers]), - 15_000, - active, - ); - useMissionPolling( - useCallback(() => { - void refreshSelectedMessages(); - }, [refreshSelectedMessages]), - 12_000, - active && Boolean(selectedChannel && selectedChannel.kind !== "global"), - ); - - // ── Real-time events ── - const refreshThreadsRef = useRef(refreshThreads); - const refreshThreadMessagesRef = useRef(refreshThreadMessages); - useEffect(() => { refreshThreadsRef.current = refreshThreads; }, [refreshThreads]); - useEffect(() => { refreshThreadMessagesRef.current = refreshThreadMessages; }, [refreshThreadMessages]); - - useEffect(() => { - if (!active) return; - const unsub = window.ade.orchestrator.onThreadEvent((event) => { - if (event.missionId !== missionId) return; - if (event.type === "thread_updated" || event.type === "message_appended" || event.type === "message_updated" || event.type === "worker_replay") { - if (threadRefreshTimerRef.current !== null) window.clearTimeout(threadRefreshTimerRef.current); - threadRefreshTimerRef.current = window.setTimeout(() => { threadRefreshTimerRef.current = null; void refreshThreadsRef.current(); }, 120); - if (messageRefreshTimerRef.current !== null) window.clearTimeout(messageRefreshTimerRef.current); - messageRefreshTimerRef.current = window.setTimeout(() => { - messageRefreshTimerRef.current = null; - const cur = selectedChannelIdRef.current; - if (cur === "global") return; - const ch = channelsRef.current.find((c) => c.id === cur); - if (ch?.threadId && (!event.threadId || event.threadId === ch.threadId)) { - void refreshThreadMessagesRef.current(ch.threadId); - } - }, 100); - } - }); - return () => { unsub(); if (threadRefreshTimerRef.current !== null) window.clearTimeout(threadRefreshTimerRef.current); if (messageRefreshTimerRef.current !== null) window.clearTimeout(messageRefreshTimerRef.current); }; - }, [active, missionId]); - - // ── Jump target handling ── - useEffect(() => { - if (!jumpTarget) return; - setJumpNotice(null); - const targetChannelId = resolveChatTargetChannelId({ target: jumpTarget, threads }); - if (targetChannelId) { - setSelectedChannelId(targetChannelId); - onJumpHandled(); - return; - } - if ( - (jumpTarget.kind === "worker" || jumpTarget.kind === "workers" || jumpTarget.kind === "teammate" || jumpTarget.kind === "coordinator" || jumpTarget.kind === "agent") - && !threads.length - ) { - return; - } - if (jumpTarget.kind === "worker" || jumpTarget.kind === "workers" || jumpTarget.kind === "agent") { - if (!threads.length) return; - const ct = threads.find((t) => t.threadType === "coordinator"); - setSelectedChannelId(ct ? `thread:${ct.id}` : "global"); - setJumpNotice("ADE has not hydrated that worker thread yet, so I landed you on the coordinator instead."); - } else if (jumpTarget.kind === "teammate") { const tt = threads.find((t) => t.threadType === "teammate"); if (tt) setSelectedChannelId(`thread:${tt.id}`); } - else { const ct = threads.find((t) => t.threadType === "coordinator"); if (ct) setSelectedChannelId(`thread:${ct.id}`); } - onJumpHandled(); - }, [jumpTarget, onJumpHandled, threads]); - - useEffect(() => { - if (selectedChannel?.kind !== "worker" && selectedChannel?.kind !== "orchestrator") return; - if (threadMessages.length > 0) setJumpNotice(null); - }, [selectedChannel, threadMessages.length]); - - // ── Displayed messages ── - const displayMessages = useMemo(() => { - if (selectedChannel?.kind === "global") { - return prepareMissionFeedItems(runView?.progressLog ?? []).map((item) => ({ - id: `mission-feed:${item.id}`, - missionId, - role: item.kind === "worker" ? "worker" : item.kind === "user" ? "user" : "orchestrator", - content: formatMissionFeedMessageContent(item.title, item.detail), - timestamp: item.at, - stepKey: item.stepKey ?? null, - attemptId: item.attemptId ?? null, - runId: runId ?? null, - metadata: { - missionFeed: true, - structuredStream: { kind: "text", itemId: item.id }, - title: item.title, - severity: item.severity, - feedKind: item.kind, - progressAudience: item.audience ?? "mission_feed", - progressSource: item.source ?? "mission", - }, - } satisfies OrchestratorChatMessage)); - } - return threadMessages; - }, [selectedChannel, threadMessages, missionId, runId, runView]); - - const attemptNameMap = useMemo(() => { - const map = new Map<string, string>(); - for (const thread of threads) { - if (!thread.attemptId) continue; - map.set(thread.attemptId, thread.title || (thread.threadType === "coordinator" ? "Orchestrator" : "Worker")); - } - return map; - }, [threads]); - const threadIntervention = useMemo( - () => findThreadIntervention({ interventions, selectedChannel, runId }), - [interventions, runId, selectedChannel], - ); - - const chatNotice = useMemo(() => { - if (runStatus === "paused") { - return { - reason: "Run is paused.", - action: selectedChannel?.kind === "global" - ? "Open the orchestrator or an active worker thread if you want to send a recovery note before resuming." - : "You can still message the coordinator or an active worker here while you decide whether to resume.", - }; - } - if (missionStatus === "intervention_required") { - return { - reason: "Mission is waiting on an intervention.", - action: selectedChannel?.kind === "global" - ? "Open the orchestrator or an active worker thread if you want to send a recovery note while you decide what to do." - : "You can still message the coordinator or an active worker here while you decide how to recover.", - }; - } - return null; - }, [missionStatus, runStatus, selectedChannel?.kind]); - - const chatBlocked = useMemo(() => { - if (missionStatus === "completed" || missionStatus === "failed" || missionStatus === "canceled") return { reason: "Mission run is closed.", action: "Start or rerun the mission to continue chat." }; - if (!runId || !runStatus) return { reason: "Orchestrator runtime is offline.", action: "Start the mission run to send directives." }; - if (runStatus === "queued" || runStatus === "bootstrapping") return { reason: "Orchestrator runtime is starting.", action: "Wait for readiness, then send directives." }; - if (selectedChannel?.kind === "orchestrator" && (selectedChannel.status !== "active" || runView?.coordinator.available === false)) { - return { reason: "The orchestrator is offline.", action: "Review the thread history, resolve the recovery action, and resume once coordinator health is restored." }; - } - if (selectedChannel?.kind === "worker") { const ws = selectedChannel.attemptId ? workerStateByAttempt.get(selectedChannel.attemptId)?.state : undefined; if (selectedChannel.status !== "active" || ws === "completed" || ws === "failed" || ws === "disposed") return { reason: "This worker is no longer running.", action: "Read the thread for history, or message the orchestrator to redirect the mission." }; } - if (runStatus === "succeeded" || runStatus === "failed" || runStatus === "canceled") return { reason: "Run is in a terminal state.", action: "Start a new run to continue chat." }; - return null; - }, [missionStatus, runId, runStatus, runView?.coordinator.available, selectedChannel, workerStateByAttempt]); - - const showStreaming = useMemo(() => { - const workerState = selectedChannel?.attemptId - ? workerStateByAttempt.get(selectedChannel.attemptId)?.state - : null; - return shouldShowWorkerStreamingIndicator({ - channelKind: selectedChannel?.kind, - channelStatus: selectedChannel?.status, - attemptId: selectedChannel?.attemptId, - workerState, - }); - }, [selectedChannel, workerStateByAttempt]); - - const missionNarrative = useMemo(() => buildMissionStateNarrative(runView), [runView]); - - const handleApproval = useCallback(async ( - sessionId: string, - itemId: string, - decision: "accept" | "accept_for_session" | "decline" | "cancel", - responseText?: string | null, - answers?: Record<string, string | string[]>, - ) => { - try { - await window.ade.agentChat.respondToInput({ sessionId, itemId, decision, responseText, answers }); - setJumpNotice(null); - await refreshThreads(); - if (selectedChannel?.threadId) await refreshThreadMessages(selectedChannel.threadId); - } catch (error) { - setJumpNotice(error instanceof Error ? error.message : String(error)); - } - }, [refreshThreadMessages, refreshThreads, selectedChannel]); - - const refreshMissionWorkspace = useCallback(async () => { - const store = missionsStore.getState(); - await store.refreshMissionList({ preserveSelection: true, silent: true }); - await store.loadMissionDetail(missionId); - await store.loadOrchestratorGraph(missionId); - }, [missionId, missionsStore]); - - const handleRunControl = useCallback(async (action: "pause" | "resume" | "cancel") => { - if (!runId || runActionBusy) return; - setRunActionBusy(action); - setJumpNotice(null); - try { - if (action === "pause") { - await window.ade.orchestrator.pauseRun({ runId, reason: "Paused from mission chat." }); - } else if (action === "resume") { - await window.ade.orchestrator.resumeRun({ runId }); - } else { - await window.ade.orchestrator.cancelRun({ runId, reason: "Canceled from mission chat." }); - } - await Promise.all([ - refreshMissionWorkspace(), - refreshThreads(), - refreshWorkers(), - refreshSelectedMessages(), - ]); - } catch (error) { - setJumpNotice(error instanceof Error ? error.message : String(error)); - } finally { - setRunActionBusy(null); - } - }, [refreshMissionWorkspace, refreshSelectedMessages, refreshThreads, refreshWorkers, runActionBusy, runId]); - - const handleAppendMentionTarget = useCallback((targetId: string) => { - setInput((current) => { - const prefix = current.trim().length > 0 && !/\s$/.test(current) ? `${current} ` : current; - return `${prefix}@${targetId} `; - }); - }, []); - - const handleSendMessage = useCallback(async (message: string) => { - const content = message.trim(); - if (!content || !selectedChannel || selectedChannel.kind === "global" || !selectedChannel.threadId || chatBlocked) return; - setSending(true); - setJumpNotice(null); - try { - await window.ade.orchestrator.sendThreadMessage({ - missionId, - threadId: selectedChannel.threadId, - content, - }); - setInput(""); - await Promise.all([ - refreshThreads(), - refreshThreadMessages(selectedChannel.threadId), - ]); - } catch (error) { - setJumpNotice(error instanceof Error ? error.message : String(error)); - } finally { - setSending(false); - } - }, [chatBlocked, missionId, refreshThreadMessages, refreshThreads, selectedChannel]); - - const runControls = useMemo(() => { - if (!runId || !runStatus) return null; - if (runStatus === "succeeded" || runStatus === "failed" || runStatus === "canceled") return null; - - const buttons: Array<{ id: "pause" | "resume" | "cancel"; label: string; tone: "accent" | "danger" }> = []; - if (runStatus === "paused") { - buttons.push({ id: "resume", label: "Resume run", tone: "accent" }); - } else { - buttons.push({ id: "pause", label: "Pause run", tone: "accent" }); - } - buttons.push({ id: "cancel", label: "Cancel run", tone: "danger" }); - - return ( - <> - <span - className="inline-flex items-center gap-1 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ - background: "color-mix(in srgb, var(--chat-accent) 10%, transparent)", - color: COLORS.textSecondary, - border: "1px solid color-mix(in srgb, var(--chat-accent) 16%, rgba(255,255,255,0.08))", - fontFamily: MONO_FONT, - }} - > - Run {runStatus.replace(/_/g, " ")} - </span> - {buttons.map((button) => { - const accentColor = button.tone === "danger" ? COLORS.danger : COLORS.accent; - const isBusy = runActionBusy === button.id; - return ( - <button - key={button.id} - type="button" - className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] transition-opacity hover:opacity-90 disabled:opacity-55" - style={{ - background: `${accentColor}14`, - color: accentColor, - border: `1px solid ${accentColor}30`, - fontFamily: MONO_FONT, - }} - onClick={() => void handleRunControl(button.id)} - disabled={runActionBusy != null} - > - {isBusy ? "Working..." : button.label} - </button> - ); - })} - </> - ); - }, [handleRunControl, runActionBusy, runId, runStatus]); - - const fallbackFooterHint = useMemo(() => { - if (selectedChannel?.kind === "global") { - return "Mission feed is read-only. Open the orchestrator or a worker thread to send a message."; - } - if (chatBlocked) { - return `${chatBlocked.reason} ${chatBlocked.action}`; - } - if (selectedChannel?.kind === "orchestrator") { - return "Coordinator history is read-only until ADE attaches a live orchestrator session."; - } - if (selectedChannel?.kind === "worker") { - return selectedChannel.status === "active" - ? "Worker history is read-only until ADE attaches the live worker session." - : "Completed worker history is read-only."; - } - return "Mission thread history is read-only."; - }, [chatBlocked, selectedChannel?.kind, selectedChannel?.status]); - - return ( - <div className="flex h-full w-full min-h-0 min-w-0 flex-1 overflow-hidden"> - <ChatChannelList - channels={channels} - orchestratorChannel={orchestratorChannel} - teammateChannels={teammateChannels} - activeWorkerChannels={activeWorkerChannels} - completedWorkerChannels={completedWorkerChannels} - selectedChannelId={selectedChannelId} - completedCollapsed={completedCollapsed} - workerStatusDot={workerStatusDotFn} - onSelectChannel={setSelectedChannelId} - onToggleCompletedCollapsed={() => setCompletedCollapsed((p) => !p)} - /> - <div className="flex min-w-0 flex-1 flex-col overflow-hidden" style={{ background: BG_PAGE }}> - <div - data-chat-appearance-root - style={chatAppearanceRootStyle} - className="m-2 min-h-0 min-w-0 max-w-full flex-1 overflow-hidden" - > - <ChatSurfaceShell - mode={missionSurfaceMode} - accentColor={missionSurfaceAccent} - chromeTint={chatChromeTint} - shellGeometry={chatShellGeometry} - contentScale={1} - className="w-full min-w-0 max-w-full rounded-[var(--chat-radius-shell)]" - bodyClassName="flex min-h-0 min-w-0 flex-1 flex-col" - footer={selectedChannel?.kind !== "global" && !chatBlocked ? ( - <ChatInput - selectedChannel={selectedChannel} - input={input} - attachments={[]} - sending={sending} - chatBlocked={Boolean(chatBlocked)} - participants={[]} - quickTargets={[]} - onInputChange={setInput} - onSend={handleSendMessage} - onAppendMentionTarget={handleAppendMentionTarget} - /> - ) : ( - <div - className="w-full min-w-0 max-w-full overflow-hidden px-4 py-3 text-[11px]" - style={{ - borderTop: `1px solid rgba(255,255,255,0.08)`, - background: "linear-gradient(180deg, rgba(20,16,29,0.96) 0%, rgba(13,10,20,0.92) 100%)", - color: COLORS.textSecondary, - fontFamily: MONO_FONT, - }} - > - {fallbackFooterHint} - </div> - )} - > - <ChatMessageArea - selectedChannel={selectedChannel} - workerStatusDot={workerStatusDotFn} - displayMessages={displayMessages} - attemptNameMap={attemptNameMap} - jumpNotice={jumpNotice} - chatNotice={chatNotice} - chatBlocked={chatBlocked} - threadIntervention={threadIntervention} - onOpenIntervention={onOpenIntervention} - showStreamingIndicator={showStreaming} - threadMessagesLoading={threadMessagesLoading} - threadMessagesLoadingMore={threadMessagesLoadingMore} - threadMessagesError={threadMessagesError} - threadMessagesHasMore={threadMessagesHasMore} - onLoadOlderMessages={loadOlderSelectedMessages} - onRetryMessages={retrySelectedMessages} - missionNarrative={missionNarrative} - runControls={runControls} - onApproval={handleApproval} - /> - </ChatSurfaceShell> - </div> - </div> - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionComputerUsePanel.tsx b/apps/desktop/src/renderer/components/missions/MissionComputerUsePanel.tsx deleted file mode 100644 index 0534d4e0b..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionComputerUsePanel.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import type { ComputerUseArtifactOwnerKind, ComputerUseArtifactView, ComputerUseOwnerSnapshot } from "../../../shared/types"; -import { COLORS, MONO_FONT, outlineButton } from "../lanes/laneDesignTokens"; -import { - buildComputerUseRoutePresets, - describeComputerUseLinks, - formatComputerUseKind, - summarizeComputerUseProof, -} from "../../lib/computerUse"; - -function isExternalUri(value: string): boolean { - return /^https?:\/\//i.test(value); -} - -function openArtifactUri(uri: string | null) { - if (!uri) return; - if (isExternalUri(uri)) { - void window.ade.app.openExternal(uri); - return; - } - void window.ade.app.revealPath(uri); -} - -export function MissionComputerUsePanel({ - missionId, - laneId, - initialSnapshot, -}: { - missionId: string; - laneId?: string | null; - initialSnapshot?: ComputerUseOwnerSnapshot | null; -}) { - const [snapshot, setSnapshot] = useState<ComputerUseOwnerSnapshot | null>(initialSnapshot ?? null); - const [selectedArtifactId, setSelectedArtifactId] = useState<string | null>(initialSnapshot?.artifacts[0]?.id ?? null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState<string | null>(null); - const [routeKind, setRouteKind] = useState<ComputerUseArtifactOwnerKind>("mission"); - const [routeTargetId, setRouteTargetId] = useState(""); - - const refresh = useCallback(async () => { - const next = await window.ade.computerUse.getOwnerSnapshot({ - owner: { kind: "mission", id: missionId }, - }); - setSnapshot(next); - setSelectedArtifactId((current) => current && next.artifacts.some((artifact) => artifact.id === current) - ? current - : next.artifacts[0]?.id ?? null); - }, [missionId]); - - useEffect(() => { - setSnapshot(initialSnapshot ?? null); - setSelectedArtifactId(initialSnapshot?.artifacts[0]?.id ?? null); - }, [initialSnapshot]); - - useEffect(() => { - if (initialSnapshot) return; - void refresh().catch((err) => { - setError(err instanceof Error ? err.message : String(err)); - }); - }, [initialSnapshot, refresh]); - - const selectedArtifact = useMemo( - () => snapshot?.artifacts.find((artifact) => artifact.id === selectedArtifactId) ?? snapshot?.artifacts[0] ?? null, - [selectedArtifactId, snapshot], - ); - - const routePresets = useMemo( - () => buildComputerUseRoutePresets({ missionId, laneId }), - [laneId, missionId], - ); - - const updateReview = useCallback(async (artifactId: string, reviewState: ComputerUseArtifactView["reviewState"], workflowState?: ComputerUseArtifactView["workflowState"]) => { - setBusy(true); - setError(null); - try { - await window.ade.computerUse.updateArtifactReview({ - artifactId, - reviewState, - ...(workflowState ? { workflowState } : {}), - }); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }, [refresh]); - - const routeArtifact = useCallback(async (artifactId: string, ownerKind: ComputerUseArtifactOwnerKind, ownerId: string) => { - if (!ownerId.trim()) return; - setBusy(true); - setError(null); - try { - await window.ade.computerUse.routeArtifact({ - artifactId, - owner: { kind: ownerKind, id: ownerId.trim() }, - }); - setRouteTargetId(""); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }, [refresh]); - - if (!snapshot) return null; - - return ( - <div className="space-y-3 rounded-sm p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex flex-wrap items-start justify-between gap-3"> - <div> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Computer-Use Proof Review - </div> - <div className="mt-1 text-[12px]" style={{ color: COLORS.textPrimary }}> - {snapshot.summary} - </div> - </div> - <div className="flex flex-wrap gap-2"> - <button type="button" style={outlineButton({ height: 26, padding: "0 8px", fontSize: 9 })} onClick={() => void refresh()} disabled={busy}> - REFRESH - </button> - </div> - </div> - - <div className="flex flex-wrap gap-3 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>{summarizeComputerUseProof(snapshot)}</span> - <span>Backend: {snapshot.activeBackend?.name ?? "not selected"}</span> - </div> - - <div className="grid gap-3 lg:grid-cols-[260px_minmax(0,1fr)]"> - <div className="space-y-2"> - {snapshot.artifacts.length === 0 ? ( - <div className="text-[10px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - No computer-use artifacts are linked to this mission yet. - </div> - ) : snapshot.artifacts.map((artifact) => { - const selected = artifact.id === selectedArtifact?.id; - return ( - <button - key={artifact.id} - type="button" - onClick={() => setSelectedArtifactId(artifact.id)} - className="w-full text-left" - style={{ - border: `1px solid ${selected ? COLORS.accent : COLORS.border}`, - background: selected ? "color-mix(in srgb, var(--color-accent) 10%, transparent)" : COLORS.recessedBg, - padding: 10, - }} - > - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {artifact.backendName} - </div> - <div className="mt-1 text-[12px]" style={{ color: COLORS.textPrimary }}> - {artifact.title} - </div> - <div className="mt-1 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {formatComputerUseKind(artifact.kind)} • {artifact.reviewState} • {artifact.workflowState} - </div> - </button> - ); - })} - </div> - - <div className="space-y-3"> - {selectedArtifact ? ( - <> - <div className="rounded-sm p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex flex-wrap items-start justify-between gap-3"> - <div> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {selectedArtifact.backendName} - </div> - <div className="mt-1 text-[14px]" style={{ color: COLORS.textPrimary }}> - {selectedArtifact.title} - </div> - {selectedArtifact.description ? ( - <div className="mt-2 text-[11px]" style={{ color: COLORS.textSecondary }}> - {selectedArtifact.description} - </div> - ) : null} - </div> - {selectedArtifact.uri ? ( - <button type="button" style={outlineButton({ height: 28, padding: "0 10px", fontSize: 9 })} onClick={() => openArtifactUri(selectedArtifact.uri)}> - {isExternalUri(selectedArtifact.uri) ? "OPEN" : "REVEAL"} - </button> - ) : null} - </div> - <div className="mt-3 flex flex-wrap gap-2 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>{formatComputerUseKind(selectedArtifact.kind)}</span> - <span>{selectedArtifact.reviewState}</span> - <span>{selectedArtifact.workflowState}</span> - <span>{describeComputerUseLinks(selectedArtifact.links)}</span> - </div> - </div> - - <div className="flex flex-wrap gap-2"> - <button type="button" style={outlineButton({ height: 26, padding: "0 8px", fontSize: 9 })} onClick={() => void updateReview(selectedArtifact.id, "accepted", "promoted")} disabled={busy}> - ACCEPT - </button> - <button type="button" style={outlineButton({ height: 26, padding: "0 8px", fontSize: 9 })} onClick={() => void updateReview(selectedArtifact.id, "needs_more")} disabled={busy}> - NEED MORE - </button> - <button type="button" style={outlineButton({ height: 26, padding: "0 8px", fontSize: 9 })} onClick={() => void updateReview(selectedArtifact.id, "dismissed", "dismissed")} disabled={busy}> - DISMISS - </button> - <button type="button" style={outlineButton({ height: 26, padding: "0 8px", fontSize: 9 })} onClick={() => void updateReview(selectedArtifact.id, "accepted", "published")} disabled={busy}> - PUBLISH - </button> - </div> - - <div className="space-y-2 rounded-sm p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Route Artifact - </div> - <div className="flex flex-wrap gap-2"> - {routePresets.map((preset) => ( - <button - key={`${preset.owner.kind}:${preset.owner.id}`} - type="button" - style={outlineButton({ height: 26, padding: "0 8px", fontSize: 9 })} - onClick={() => void routeArtifact(selectedArtifact.id, preset.owner.kind, preset.owner.id)} - disabled={busy} - > - {preset.label} - </button> - ))} - </div> - <div className="grid gap-2 md:grid-cols-[160px_minmax(0,1fr)_auto]"> - <select - value={routeKind} - onChange={(e) => setRouteKind(e.target.value as ComputerUseArtifactOwnerKind)} - className="h-8 px-2 text-[11px] outline-none" - style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, color: COLORS.textPrimary, fontFamily: MONO_FONT }} - > - <option value="mission">mission</option> - <option value="lane">lane</option> - <option value="github_pr">GitHub PR</option> - <option value="linear_issue">linear issue</option> - <option value="automation_run">automation run</option> - </select> - <input - value={routeTargetId} - onChange={(e) => setRouteTargetId(e.target.value)} - placeholder="Target ID" - className="h-8 px-2 text-[11px] outline-none" - style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, color: COLORS.textPrimary, fontFamily: MONO_FONT }} - /> - <button type="button" style={outlineButton({ height: 32, padding: "0 10px", fontSize: 9 })} onClick={() => void routeArtifact(selectedArtifact.id, routeKind, routeTargetId)} disabled={busy || !routeTargetId.trim()}> - ATTACH - </button> - </div> - </div> - </> - ) : null} - </div> - </div> - - {error ? ( - <div className="text-[10px]" style={{ color: COLORS.warning, fontFamily: MONO_FONT }}> - {error} - </div> - ) : null} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionControlOfficePanel.tsx b/apps/desktop/src/renderer/components/missions/MissionControlOfficePanel.tsx deleted file mode 100644 index a5a5792b0..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionControlOfficePanel.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import React, { useMemo } from "react"; -import { - ArrowRight, - CheckCircle, - Crown, - FlagBanner, - GitBranch, - Pulse, - UsersThree, - WarningCircle, - Wrench, -} from "@phosphor-icons/react"; -import type { - MissionDetail, - MissionIntervention, - MissionRunView, - MissionRunViewProgressItem, - MissionRunViewWorkerSummary, - OrchestratorRunGraph, - PhaseCard, -} from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; -import { relativeWhen } from "../../lib/format"; -import { isRecord } from "./missionHelpers"; -import { selectCurrentMissionWorkers, sortMissionWorkers } from "./missionWorkerPresentation"; - -type MissionControlOfficePanelProps = { - mission: MissionDetail; - runView: MissionRunView | null; - runGraph: OrchestratorRunGraph | null; - phases?: PhaseCard[] | null; - onOpenIntervention?: (interventionId: string) => void; -}; - -type OfficeDeskStatus = "idle" | "active" | "blocked" | "done" | "failed"; - -type OfficeDesk = { - key: string; - label: string; - detail: string; - status: OfficeDeskStatus; - icon: typeof Crown; -}; - -const STATUS_TONE: Record<OfficeDeskStatus, { color: string; label: string }> = { - idle: { color: COLORS.textDim, label: "idle" }, - active: { color: COLORS.accent, label: "active" }, - blocked: { color: COLORS.warning, label: "blocked" }, - done: { color: COLORS.success, label: "done" }, - failed: { color: COLORS.danger, label: "failed" }, -}; - -function pickPhases(args: { - mission: MissionDetail; - runGraph: OrchestratorRunGraph | null; - phases?: PhaseCard[] | null; -}): PhaseCard[] { - if (args.phases?.length) return args.phases.slice().sort((a, b) => a.position - b.position); - const runMetadata = isRecord(args.runGraph?.run.metadata) ? args.runGraph.run.metadata : null; - const runPhaseOverride = Array.isArray(runMetadata?.phaseOverride) - ? (runMetadata.phaseOverride as PhaseCard[]) - : []; - if (runPhaseOverride.length) return runPhaseOverride.slice().sort((a, b) => a.position - b.position); - return (args.mission.phaseConfiguration?.selectedPhases ?? []).slice().sort((a, b) => a.position - b.position); -} - -function summarizeWorkers(workers: MissionRunViewWorkerSummary[]) { - return { - active: workers.filter((worker) => worker.status === "active").length, - blocked: workers.filter((worker) => worker.status === "blocked").length, - completed: workers.filter((worker) => worker.status === "completed").length, - failed: workers.filter((worker) => worker.status === "failed").length, - total: workers.length, - }; -} - -function missionDeskStatus(args: { - mission: MissionDetail; - runView: MissionRunView | null; - runGraph: OrchestratorRunGraph | null; - workers: ReturnType<typeof summarizeWorkers>; - openInterventions: MissionIntervention[]; -}): OfficeDesk[] { - const runStatus = args.runView?.lifecycle.displayStatus ?? "not_started"; - const planningDone = (args.runGraph?.steps ?? []).some((step) => { - const metadata = isRecord(step.metadata) ? step.metadata : null; - return step.status === "succeeded" - && (metadata?.phaseKey === "planning" || metadata?.stepType === "planning" || metadata?.workerIntent === "planner"); - }); - const resultLaneReady = Boolean(args.mission.resultLaneId); - const closeoutOpen = (args.runView?.closeoutRequirements ?? []).filter((requirement) => - requirement.status !== "present" && requirement.status !== "waived" && requirement.required !== false - ).length; - const coordinatorBlocked = args.runView?.coordinator.available === false || args.openInterventions.length > 0; - const coordinatorStatus: OfficeDeskStatus = - runStatus === "failed" ? "failed" : coordinatorBlocked ? "blocked" : runStatus === "running" ? "active" : runStatus === "completed" ? "done" : "idle"; - - return [ - { - key: "planner", - label: "Planner", - detail: planningDone ? "Plan captured" : runStatus === "starting" || runStatus === "running" ? "Clarifying scope and shaping the DAG" : "Waiting for mission start", - status: planningDone ? "done" : runStatus === "running" || runStatus === "starting" ? "active" : "idle", - icon: FlagBanner, - }, - { - key: "orchestrator", - label: "Orchestrator", - detail: args.runView?.coordinator.summary ?? args.runView?.lifecycle.summary ?? "No coordinator runtime yet", - status: coordinatorStatus, - icon: Crown, - }, - { - key: "workers", - label: "Workers", - detail: `${args.workers.active} active, ${args.workers.completed} done, ${args.workers.blocked} blocked`, - status: args.workers.failed > 0 ? "failed" : args.workers.blocked > 0 ? "blocked" : args.workers.active > 0 ? "active" : args.workers.completed > 0 ? "done" : "idle", - icon: UsersThree, - }, - { - key: "integration", - label: "Integration", - detail: resultLaneReady ? `Result lane: ${args.mission.resultLaneName ?? args.mission.resultLaneId}` : "Result lane assembly pending", - status: resultLaneReady ? "done" : runStatus === "completed" && closeoutOpen > 0 ? "blocked" : runStatus === "running" ? "active" : "idle", - icon: GitBranch, - }, - { - key: "closeout", - label: "Closeout", - detail: closeoutOpen > 0 ? `${closeoutOpen} required check${closeoutOpen === 1 ? "" : "s"} remaining` : "Evidence and summary are clear", - status: closeoutOpen > 0 ? "blocked" : resultLaneReady || runStatus === "completed" ? "done" : "idle", - icon: CheckCircle, - }, - ]; -} - -function DeskCard({ desk }: { desk: OfficeDesk }) { - const tone = STATUS_TONE[desk.status]; - const Icon = desk.icon; - return ( - <div - className="min-w-[150px] flex-1 p-3" - style={{ - background: COLORS.cardBg, - border: `1px solid ${desk.status === "blocked" || desk.status === "failed" ? tone.color + "55" : COLORS.border}`, - }} - > - <div className="flex items-center justify-between gap-2"> - <div className="flex items-center gap-2"> - <Icon size={16} weight="fill" style={{ color: tone.color }} /> - <span className="text-[11px] font-semibold" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}> - {desk.label} - </span> - </div> - <span - className="text-[9px] font-bold uppercase tracking-[0.12em]" - style={{ color: tone.color, fontFamily: MONO_FONT }} - > - {tone.label} - </span> - </div> - <div className="mt-2 text-[11px] leading-5" style={{ color: COLORS.textSecondary }}> - {desk.detail} - </div> - </div> - ); -} - -function PhaseRunway({ - phases, - activePhaseKey, -}: { - phases: PhaseCard[]; - activePhaseKey: string | null; -}) { - if (!phases.length) return null; - const activeIndex = phases.findIndex((phase) => phase.phaseKey === activePhaseKey); - return ( - <div className="flex min-w-0 items-center gap-1 overflow-x-auto py-1"> - {phases.map((phase, index) => { - const isActive = phase.phaseKey === activePhaseKey; - const isDone = activeIndex >= 0 && index < activeIndex; - const color = isActive ? COLORS.accent : isDone ? COLORS.success : COLORS.textDim; - return ( - <React.Fragment key={phase.id}> - <div className="flex shrink-0 items-center gap-1.5"> - <span - className="inline-block h-2 w-2 rounded-full" - style={{ background: color }} - /> - <span className="text-[10px]" style={{ color, fontFamily: MONO_FONT }}> - {phase.name} - </span> - </div> - {index < phases.length - 1 ? <ArrowRight size={10} weight="bold" style={{ color: COLORS.textDim }} /> : null} - </React.Fragment> - ); - })} - </div> - ); -} - -function WorkerChip({ worker }: { worker: MissionRunViewWorkerSummary }) { - const tone = worker.status === "failed" - ? COLORS.danger - : worker.status === "blocked" - ? COLORS.warning - : worker.status === "active" - ? COLORS.accent - : COLORS.textDim; - return ( - <div - className="flex min-w-0 items-center gap-2 px-2 py-1.5" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }} - > - <span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: tone }} /> - <span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: COLORS.textPrimary }}> - {worker.stepTitle ?? worker.stepKey ?? "Worker"} - </span> - <span className="shrink-0 text-[9px] uppercase tracking-[0.12em]" style={{ color: tone, fontFamily: MONO_FONT }}> - {worker.status} - </span> - </div> - ); -} - -function ProgressLine({ item }: { item: MissionRunViewProgressItem }) { - const color = item.severity === "error" - ? COLORS.danger - : item.severity === "warning" - ? COLORS.warning - : item.severity === "success" - ? COLORS.success - : COLORS.accent; - return ( - <div className="flex items-start gap-2 text-[11px]"> - <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: color }} /> - <div className="min-w-0 flex-1"> - <div className="truncate" style={{ color: COLORS.textPrimary }}>{item.title}</div> - {item.detail ? ( - <div className="mt-0.5 line-clamp-2" style={{ color: COLORS.textMuted }}> - {item.detail} - </div> - ) : null} - </div> - <span className="shrink-0 text-[10px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - {relativeWhen(item.at)} - </span> - </div> - ); -} - -export const MissionControlOfficePanel = React.memo(function MissionControlOfficePanel({ - mission, - runView, - runGraph, - phases: providedPhases, - onOpenIntervention, -}: MissionControlOfficePanelProps) { - const phases = useMemo( - () => pickPhases({ mission, runGraph, phases: providedPhases }), - [mission, providedPhases, runGraph], - ); - const openInterventions = useMemo( - () => mission.interventions.filter((intervention) => intervention.status === "open"), - [mission.interventions], - ); - const currentWorkers = useMemo( - () => selectCurrentMissionWorkers(runView?.workers ?? []), - [runView?.workers], - ); - const workerSummary = useMemo(() => summarizeWorkers(currentWorkers), [currentWorkers]); - const desks = useMemo( - () => missionDeskStatus({ mission, runView, runGraph, workers: workerSummary, openInterventions }), - [mission, openInterventions, runGraph, runView, workerSummary], - ); - const visibleWorkers = useMemo( - () => sortMissionWorkers(currentWorkers).slice(0, 8), - [currentWorkers], - ); - const latestProgress = useMemo(() => (runView?.progressLog ?? []).slice(0, 5), [runView?.progressLog]); - - return ( - <section className="space-y-3"> - <div className="p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex flex-wrap items-center justify-between gap-3"> - <div> - <div className="flex items-center gap-2"> - <Pulse size={15} weight="fill" style={{ color: COLORS.accent }} /> - <div className="text-[12px] font-semibold" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}> - Mission control - </div> - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textMuted }}> - Planner, orchestrator, workers, integration, and closeout in one live surface. - </div> - </div> - {openInterventions.length > 0 ? ( - <button - type="button" - onClick={() => onOpenIntervention?.(openInterventions[0]!.id)} - className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-[0.12em]" - style={{ color: COLORS.warning, background: `${COLORS.warning}12`, border: `1px solid ${COLORS.warning}35`, fontFamily: MONO_FONT }} - > - <WarningCircle size={13} weight="bold" /> - {openInterventions.length} intervention{openInterventions.length === 1 ? "" : "s"} - </button> - ) : null} - </div> - <div className="mt-3"> - <PhaseRunway phases={phases} activePhaseKey={runView?.active.phaseKey ?? null} /> - </div> - </div> - - <div className="grid gap-2 xl:grid-cols-5 md:grid-cols-3 sm:grid-cols-2"> - {desks.map((desk) => <DeskCard key={desk.key} desk={desk} />)} - </div> - - <div className="grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]"> - <section className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - <Wrench size={13} weight="fill" /> - Agent floor - </div> - <div className="mt-2 space-y-1.5"> - {visibleWorkers.length > 0 ? ( - visibleWorkers.map((worker, index) => ( - <WorkerChip key={worker.attemptId ?? worker.stepId ?? worker.stepKey ?? worker.sessionId ?? `worker-${index}`} worker={worker} /> - )) - ) : ( - <div className="py-6 text-center text-[11px]" style={{ color: COLORS.textMuted }}> - No workers have checked in yet. - </div> - )} - </div> - </section> - - <section className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - <Pulse size={13} weight="fill" /> - Latest communications - </div> - <div className="mt-2 space-y-2"> - {latestProgress.length > 0 ? ( - latestProgress.map((item) => <ProgressLine key={item.id} item={item} />) - ) : ( - <div className="py-6 text-center text-[11px]" style={{ color: COLORS.textMuted }}> - Mission updates will appear here once the run starts. - </div> - )} - </div> - </section> - </div> - </section> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionCreateDialogHost.tsx b/apps/desktop/src/renderer/components/missions/MissionCreateDialogHost.tsx deleted file mode 100644 index fae5997d2..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionCreateDialogHost.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { CreateMissionDialog, type CreateDraft, type CreateMissionDefaults } from "./CreateMissionDialog"; -import { closeMissionCreateDialog, resetMissionCreateDialog, useMissionCreateDialogStore } from "./missionCreateDialogStore"; - -type MissionCreateDialogHostProps = { - lanes: Array<{ id: string; name: string }>; - defaultLaneId?: string | null; - missionDefaults?: CreateMissionDefaults | null; - onLaunch: (draft: CreateDraft) => Promise<void>; -}; - -export const MissionCreateDialogHost = React.memo(function MissionCreateDialogHost({ - lanes, - defaultLaneId, - missionDefaults, - onLaunch, -}: MissionCreateDialogHostProps) { - const open = useMissionCreateDialogStore((state) => state.open); - const resetVersion = useMissionCreateDialogStore((state) => state.resetVersion); - const [busy, setBusy] = useState(false); - - const handleClose = useCallback(() => { - if (busy) return; - closeMissionCreateDialog(); - }, [busy]); - - const handleLaunch = useCallback(async (draft: CreateDraft) => { - setBusy(true); - try { - await onLaunch(draft); - resetMissionCreateDialog(); - } catch { - // The page-level launch handler records the user-facing error in the Missions store. - } finally { - setBusy(false); - } - }, [onLaunch]); - - if (!open && !busy) { - return null; - } - - return ( - <CreateMissionDialog - open={open} - onClose={handleClose} - onLaunch={handleLaunch} - busy={busy} - lanes={lanes} - defaultLaneId={defaultLaneId} - missionDefaults={missionDefaults} - resetVersion={resetVersion} - /> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionDetailView.tsx b/apps/desktop/src/renderer/components/missions/MissionDetailView.tsx deleted file mode 100644 index 3819346b5..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionDetailView.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useMemo, useEffect, useRef, useCallback } from "react"; -import { X, WarningCircle } from "@phosphor-icons/react"; -import { motion, AnimatePresence } from "motion/react"; -import { type ClarificationQuiz, type MissionInterventionResolutionKind, type MissionRunView, resolutionKindLabel } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; -import { useMissionsStore, type MissionAttentionToast } from "./useMissionsStore"; -import { MissionHeader } from "./MissionHeader"; -import { InterventionPanel, isQuizIntervention, isBlockingManualInputIntervention } from "./InterventionPanel"; -import { MissionTabNavigation, MissionTabContent } from "./MissionTabContainer"; -import { MissionsHomeDashboard } from "./MissionsHomeDashboard"; -import { ClarificationQuizModal } from "./ClarificationQuizModal"; -import { ManualInputResponseModal } from "./ManualInputResponseModal"; -import { openMissionCreateDialog } from "./missionCreateDialogStore"; - -/* Re-export for backward compat */ -export { ManageMissionDialog, MissionContextMenu } from "./ManageMissionDialog"; - -/* ════════════════════ MISSION DETAIL VIEW ════════════════════ */ - -export function MissionDetailView({ runView }: { runView: MissionRunView | null }) { - const selectedMissionId = useMissionsStore((s) => s.selectedMissionId); - const selectedMission = useMissionsStore((s) => s.selectedMission); - const dashboard = useMissionsStore((s) => s.dashboard); - const error = useMissionsStore((s) => s.error); - const activeTab = useMissionsStore((s) => s.activeTab); - const activeInterventionId = useMissionsStore((s) => s.activeInterventionId); - - const setSelectedMissionId = useMissionsStore((s) => s.setSelectedMissionId); - const setError = useMissionsStore((s) => s.setError); - const setActiveInterventionId = useMissionsStore((s) => s.setActiveInterventionId); - const setSteerBusy = useMissionsStore((s) => s.setSteerBusy); - const refreshMissionList = useMissionsStore((s) => s.refreshMissionList); - const selectMission = useMissionsStore((s) => s.selectMission); - - const chatFocused = activeTab === "chat"; - const compactPhaseChrome = chatFocused; - - const handleInterventionResponse = useCallback( - async ( - interventionId: string, - directiveText: string, - resolutionKind: MissionInterventionResolutionKind = "answer_provided", - ) => { - if (!selectedMission) return; - setSteerBusy(true); - try { - await window.ade.orchestrator.steerMission({ - missionId: selectedMission.id, - interventionId, - directive: directiveText, - priority: "instruction", - resolutionKind, - }); - setActiveInterventionId(null); - await refreshMissionList({ preserveSelection: true, silent: true }); - await selectMission(selectedMission.id); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSteerBusy(false); - } - }, - [refreshMissionList, selectMission, selectedMission, setActiveInterventionId, setError, setSteerBusy], - ); - - if (!selectedMissionId) { - return ( - <MissionsHomeDashboard - snapshot={dashboard} - onNewMission={() => openMissionCreateDialog()} - onViewMission={(id) => setSelectedMissionId(id)} - /> - ); - } - - return ( - <> - <MissionHeader runView={runView} /> - <AnimatePresence> - {error && ( - <motion.div - initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} - className="px-4 py-2 text-[11px] flex items-center justify-between" - style={{ borderBottom: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", background: "color-mix(in srgb, var(--color-error) 18%, transparent)", color: COLORS.danger }} - > - <span>{error}</span> - <button onClick={() => setError(null)} style={{ color: COLORS.danger }}><X className="h-3 w-3" /></button> - </motion.div> - )} - </AnimatePresence> - <InterventionPanel compact={compactPhaseChrome} /> - <MissionTabNavigation /> - <MissionTabContent runView={runView} /> - <InterventionModals onInterventionResponse={handleInterventionResponse} /> - <AttentionToasts /> - </> - ); -} - -/* ════════════════════ INTERVENTION MODALS ════════════════════ */ - -function InterventionModals({ - onInterventionResponse, -}: { - onInterventionResponse: ( - id: string, - directive: string, - resolutionKind?: MissionInterventionResolutionKind, - ) => Promise<void>; -}) { - const activeInterventionId = useMissionsStore((s) => s.activeInterventionId); - const selectedMission = useMissionsStore((s) => s.selectedMission); - const setActiveInterventionId = useMissionsStore((s) => s.setActiveInterventionId); - // autoOpenedRef removed (VAL-UX-005) — no more auto-open modal - - const openManualInputInterventions = useMemo( - () => selectedMission?.interventions.filter((i) => i.interventionType === "manual_input" && i.status === "open") ?? [], - [selectedMission], - ); - const blockingInterventions = useMemo( - () => openManualInputInterventions.filter((i) => isBlockingManualInputIntervention(i)), - [openManualInputInterventions], - ); - - // Auto-open modals removed (VAL-UX-005) — interventions shown in dedicated panel instead. - // Users can still click "RESOLVE" in the InterventionPanel to open the modal. - - useEffect(() => { - if (!activeInterventionId) return; - if (!(selectedMission?.interventions.some((i) => i.id === activeInterventionId) ?? false)) setActiveInterventionId(null); - }, [activeInterventionId, selectedMission, setActiveInterventionId]); - - if (!activeInterventionId || !selectedMission) return null; - const iv = selectedMission.interventions.find((i) => i.id === activeInterventionId); - if (!iv || iv.status !== "open" || iv.interventionType !== "manual_input") return null; - - if (isQuizIntervention(iv)) { - const phase = typeof iv.metadata.phase === "string" ? iv.metadata.phase : null; - return ( - <ClarificationQuizModal - interventionId={iv.id} missionId={selectedMission.id} questions={iv.metadata.questions} phase={phase} - onClose={() => setActiveInterventionId(null)} - onSubmit={async (quiz: ClarificationQuiz, resolutionKind = "answer_provided") => { - const lines = quiz.answers.map((a, i) => { - const q = quiz.questions[i]?.question?.trim() || `Question ${i + 1}`; - return `- ${q}: ${a.answer} (${a.source === "default_assumption" ? "default assumption" : "user answer"})`; - }); - const directive = - resolutionKind !== "answer_provided" - ? resolutionKindLabel(resolutionKind as MissionInterventionResolutionKind) - : ["Coordinator question answers:", ...lines, "Proceed using these answers."].join("\n"); - await onInterventionResponse(iv.id, directive, resolutionKind); - }} - /> - ); - } - return ( - <ManualInputResponseModal - intervention={iv} onClose={() => setActiveInterventionId(null)} - onSubmit={async (answer, resolutionKind = "answer_provided") => { - await onInterventionResponse(iv.id, answer, resolutionKind); - }} - /> - ); -} - -/* ════════════════════ ATTENTION TOASTS ════════════════════ */ - -function AttentionToasts() { - const attentionToasts = useMissionsStore((s) => s.attentionToasts); - const setAttentionToasts = useMissionsStore((s) => s.setAttentionToasts); - const dismiss = useCallback((id: string) => { setAttentionToasts(attentionToasts.filter((t) => t.id !== id)); }, [attentionToasts, setAttentionToasts]); - - if (attentionToasts.length === 0) return null; - return ( - <div style={{ position: "absolute", bottom: 12, right: 12, zIndex: 95, display: "flex", flexDirection: "column", gap: 6, width: "min(340px, calc(100vw - 20px))", pointerEvents: "none" }}> - {attentionToasts.map((toast) => { - const color = toast.severity === "error" ? COLORS.danger : COLORS.warning; - return ( - <div key={toast.id} style={{ pointerEvents: "auto", background: COLORS.cardBg, border: `1px solid ${color}40`, borderLeft: `3px solid ${color}`, padding: "8px 12px", fontFamily: MONO_FONT, fontSize: 11, boxShadow: "0 4px 12px rgba(0,0,0,0.3)" }}> - <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}> - <div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}> - <WarningCircle weight="bold" style={{ color, width: 14, height: 14, flexShrink: 0 }} /> - <div style={{ minWidth: 0 }}> - <div style={{ fontWeight: 700, color, fontSize: 10, textTransform: "uppercase", letterSpacing: "0.8px" }}>{toast.message}</div> - <div style={{ color: COLORS.textSecondary, fontSize: 11, fontFamily: SANS_FONT, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{toast.missionTitle}</div> - </div> - </div> - <button type="button" onClick={() => dismiss(toast.id)} style={{ flexShrink: 0, background: "transparent", border: "none", color: COLORS.textMuted, cursor: "pointer", padding: 0, fontSize: 14, lineHeight: 1 }} title="Dismiss">{"\u00D7"}</button> - </div> - </div> - ); - })} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionHeader.tsx b/apps/desktop/src/renderer/components/missions/MissionHeader.tsx deleted file mode 100644 index f9f3c6a6b..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionHeader.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import { useMemo, useCallback, useEffect, useState } from "react"; -import { - Clock, - SpinnerGap, - Play, - Stop, - GearSix, - GitBranch, - Archive, - XCircle, - Gauge, -} from "@phosphor-icons/react"; -import type { - MissionRunView, - MissionSummary, - OrchestratorExecutorKind, - StartOrchestratorRunFromMissionArgs, - UsageSnapshot, -} from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT, primaryButton, outlineButton, dangerButton } from "../lanes/laneDesignTokens"; -import { useAppStore } from "../../state/appStore"; -import { relativeWhen } from "../../lib/format"; -import { - PRIORITY_STYLES, - TERMINAL_MISSION_STATUSES, - ElapsedTime, - computeProgress, - LIFECYCLE_ACTIONS, - formatResetCountdown, - getMissionStatusBadgeConfig, - usagePercentColor, -} from "./missionHelpers"; -import { useMissionsStore, useMissionsStoreApi, type MissionsStore } from "./useMissionsStore"; -import { LaneAccentDot } from "../lanes/LaneAccentDot"; -import { useShallow } from "zustand/react/shallow"; -import { useMissionPageActive } from "./MissionActiveContext"; - -const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "canceled"]); - -/* ── Fine-grained derived selectors (VAL-ARCH-008) ── */ -const selectHeaderData = (s: MissionsStore) => ({ - selectedMission: s.selectedMission, - runGraph: s.runGraph, - runBusy: s.runBusy, - checkpointStatus: s.checkpointStatus, - activeTab: s.activeTab, - cleanupBusy: s.cleanupBusy, - manageMissionBusy: s.manageMissionBusy, -}); - -const selectHeaderMissionSummary = (s: MissionsStore) => { - if (!s.selectedMissionId) return null; - return s.missions.find((m) => m.id === s.selectedMissionId) ?? null; -}; - -/* ════════════════════ MISSION HEADER ════════════════════ */ - -export function MissionHeader({ runView }: { runView: MissionRunView | null }) { - const missionsStore = useMissionsStoreApi(); - /* ── Grouped selector for render data (VAL-ARCH-008) ── */ - const { - selectedMission, - runGraph, - runBusy, - checkpointStatus, - activeTab, - cleanupBusy, - manageMissionBusy, - } = useMissionsStore(useShallow(selectHeaderData)); - - const selectedMissionSummary = useMissionsStore(selectHeaderMissionSummary); - - /* ── Header progress should reflect the whole user-facing plan, including - pending task shells that have not yet become worker steps. ── */ - const executionProgress = useMemo( - () => computeProgress(runGraph?.steps ?? [], { includeDisplayOnlyTaskShells: true }), - [runGraph?.steps], - ); - - const runAutopilotState = useMemo(() => { - const autopilot = - runGraph?.run.metadata && - typeof runGraph.run.metadata.autopilot === "object" && - !Array.isArray(runGraph.run.metadata.autopilot) - ? (runGraph.run.metadata.autopilot as Record<string, unknown>) - : null; - return { - enabled: autopilot?.enabled === true, - executor: typeof autopilot?.executorKind === "string" ? autopilot.executorKind : null, - }; - }, [runGraph]); - - const canStartOrRerun = - !runGraph || - runGraph.run.status === "succeeded" || - runGraph.run.status === "failed" || - runGraph.run.status === "canceled"; - const canCancelRun = Boolean( - runGraph && - runGraph.run.status !== "succeeded" && - runGraph.run.status !== "failed" && - runGraph.run.status !== "canceled", - ); - const canResumeRun = runGraph?.run.status === "paused"; - const canPauseRun = Boolean( - runGraph && (runGraph.run.status === "active" || runGraph.run.status === "bootstrapping"), - ); - const missionElapsedEndedAt = useMemo(() => { - if (!selectedMission) return null; - const runStatus = runGraph?.run.status ?? null; - if (runStatus === "paused") return runGraph?.run.updatedAt ?? selectedMission.updatedAt; - if (runStatus === "canceled" || runStatus === "failed" || runStatus === "succeeded") { - return runGraph?.run.completedAt ?? runGraph?.run.updatedAt ?? selectedMission.completedAt ?? selectedMission.updatedAt; - } - if (selectedMission.status === "intervention_required") return selectedMission.updatedAt; - if (TERMINAL_MISSION_STATUSES.has(selectedMission.status)) { - return selectedMission.completedAt ?? selectedMission.updatedAt; - } - return null; - }, [runGraph?.run.completedAt, runGraph?.run.status, runGraph?.run.updatedAt, selectedMission]); - - const checkpointIndicatorLabel = checkpointStatus ? relativeWhen(checkpointStatus.savedAt) : "pending"; - const checkpointIndicatorTooltip = checkpointStatus - ? `Last checkpoint: ${relativeWhen(checkpointStatus.savedAt)} | ${checkpointStatus.turnCount} turns | ${checkpointStatus.compactionCount} compactions` - : "Last checkpoint: pending"; - - const chatFocused = activeTab === "chat"; - - /* ── Actions (access store imperatively to avoid extra subscriptions) ── */ - const openManageMissionDialog = useCallback((mission: MissionSummary) => { - const s = missionsStore.getState(); - s.setManageMission(mission); - s.setManageMissionCleanupLanes(false); - s.setManageMissionError(null); - s.setManageMissionOpen(true); - }, [missionsStore]); - - const handleStartRun = useCallback(async () => { - const s = missionsStore.getState(); - if (!s.selectedMission) return; - s.setRunBusy(true); - try { - const autopilot = - s.runGraph?.run.metadata && - typeof s.runGraph.run.metadata.autopilot === "object" && - !Array.isArray(s.runGraph.run.metadata.autopilot) - ? (s.runGraph.run.metadata.autopilot as Record<string, unknown>) - : null; - const executorKind = typeof autopilot?.executorKind === "string" ? autopilot.executorKind : null; - const fallbackExecutor: OrchestratorExecutorKind = - executorKind && executorKind.length > 0 - ? (executorKind as OrchestratorExecutorKind) - : "opencode"; - const plannerProvider: "claude" | "codex" | null = - executorKind === "claude" || executorKind === "codex" - ? (executorKind as "claude" | "codex") - : null; - const startArgs = { - missionId: s.selectedMission.id, - runMode: "autopilot", - autopilotOwnerId: "missions-autopilot", - defaultExecutorKind: fallbackExecutor, - defaultRetryLimit: 1, - plannerProvider: plannerProvider ?? null, - } satisfies StartOrchestratorRunFromMissionArgs; - await window.ade.orchestrator.startRunFromMission(startArgs); - await Promise.all([ - s.loadOrchestratorGraph(s.selectedMission.id), - s.loadMissionDetail(s.selectedMission.id), - s.refreshMissionList({ preserveSelection: true, silent: true }), - ]); - s.setError(null); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setRunBusy(false); - } - }, [missionsStore]); - - const handlePauseRun = useCallback(async () => { - const s = missionsStore.getState(); - if (!s.runGraph || !s.selectedMission) return; - s.setRunBusy(true); - try { - await window.ade.orchestrator.pauseRun({ runId: s.runGraph.run.id, reason: "Paused from Missions UI." }); - await s.loadOrchestratorGraph(s.selectedMission.id); - s.setError(null); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setRunBusy(false); - } - }, [missionsStore]); - - const handleCancelRun = useCallback(async () => { - const s = missionsStore.getState(); - if (!s.runGraph || !s.selectedMission) return; - s.setRunBusy(true); - try { - await window.ade.orchestrator.cancelRun({ runId: s.runGraph.run.id, reason: "Canceled from Missions UI." }); - await s.loadOrchestratorGraph(s.selectedMission.id); - s.setError(null); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setRunBusy(false); - } - }, [missionsStore]); - - const handleResumeRun = useCallback(async () => { - const s = missionsStore.getState(); - if (!s.runGraph || !s.selectedMission) return; - s.setRunBusy(true); - try { - await window.ade.orchestrator.resumeRun({ runId: s.runGraph.run.id }); - await s.loadOrchestratorGraph(s.selectedMission.id); - s.setError(null); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setRunBusy(false); - } - }, [missionsStore]); - - const handleArchiveMission = useCallback(async () => { - const s = missionsStore.getState(); - if (!s.selectedMission) return; - if (!TERMINAL_MISSION_STATUSES.has(s.selectedMission.status)) return; - if (!window.confirm("Archive this mission? This will remove it from the active list.")) return; - s.setCleanupBusy(true); - try { - // Clean up lanes first - let cleanupWarning: string | null = null; - try { - const result = await window.ade.orchestrator.cleanupTeamResources({ - missionId: s.selectedMission.id, - cleanupLanes: true, - }); - if (result.laneErrors.length > 0) { - cleanupWarning = `Mission archived, but lane cleanup archived ${result.lanesArchived.length}/${result.laneIds.length} lane(s). ${result.laneErrors.length} lane(s) failed.`; - } - } catch { /* lane cleanup is best-effort */ } - // Actually archive the mission via the mission service API - await window.ade.missions.archive({ missionId: s.selectedMission.id }); - await s.refreshMissionList({ preserveSelection: true, silent: true }); - await s.loadDashboard(); - s.setError(cleanupWarning); - } catch (err) { - s.setError(err instanceof Error ? err.message : String(err)); - } finally { - s.setCleanupBusy(false); - } - }, [missionsStore]); - - if (!selectedMission) return null; - const statusBadgeConfig = getMissionStatusBadgeConfig( - selectedMission.status, - runView?.lifecycle.displayStatus ?? null, - ); - - return ( - <div - className="flex items-center gap-3 shrink-0 px-4 py-2" - style={{ borderBottom: `1px solid ${COLORS.border}`, background: COLORS.cardBg }} - > - <div className="min-w-0 flex-1"> - <div className="flex items-center gap-2"> - <h2 - className="truncate text-sm font-bold" - style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }} - > - {selectedMission.title} - </h2> - <span - className="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-[1px]" - style={{ background: statusBadgeConfig.background, color: statusBadgeConfig.color, border: statusBadgeConfig.border, fontFamily: MONO_FONT }} - > - {statusBadgeConfig.label} - </span> - {selectedMission.priority !== "normal" && - (() => { - const p = PRIORITY_STYLES[selectedMission.priority]; - return ( - <span - className="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-[1px]" - style={{ background: p.background, color: p.color, border: p.border, fontFamily: MONO_FONT }} - > - {selectedMission.priority} - </span> - ); - })()} - </div> - <div - className="mt-0.5 flex items-center gap-2 text-[10px]" - style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }} - > - <span> - <Clock className="inline h-3 w-3 mr-0.5" /> - <ElapsedTime startedAt={selectedMission.startedAt ?? null} endedAt={missionElapsedEndedAt} /> - </span> - {selectedMission.laneName && ( - <MissionLaneChip laneId={selectedMission.laneId ?? null} laneName={selectedMission.laneName} /> - )} - {executionProgress.total > 0 && ( - <span> - {executionProgress.completed}/{executionProgress.total} steps - </span> - )} - </div> - {!chatFocused && executionProgress.total > 0 && executionProgress.pct > 0 && ( - <MissionProgressBar pct={executionProgress.pct} /> - )} - </div> - - {/* Usage meter (VAL-USAGE-001) */} - <CompactUsageMeter /> - - {/* Quick actions (VAL-UX-006 lifecycle actions) */} - <div className="flex items-center gap-1.5"> - {selectedMissionSummary && ( - <button - style={outlineButton()} - onClick={() => openManageMissionDialog(selectedMissionSummary)} - disabled={manageMissionBusy} - > - <GearSix className="h-3 w-3" /> - MANAGE - </button> - )} - {canStartOrRerun && ( - <button style={primaryButton()} onClick={() => void handleStartRun()} disabled={runBusy}> - {runBusy ? <SpinnerGap className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />} - {runGraph ? "RERUN" : "START"} - </button> - )} - {canResumeRun && ( - <button style={outlineButton()} onClick={() => void handleResumeRun()} disabled={runBusy}> - <Play className="h-3 w-3" /> - RESUME - </button> - )} - {/* Pause Run — amber */} - {canPauseRun && ( - <button - style={outlineButton({ - color: LIFECYCLE_ACTIONS.stop_run.color, - border: `1px solid ${LIFECYCLE_ACTIONS.stop_run.color}40`, - background: `${LIFECYCLE_ACTIONS.stop_run.color}12`, - })} - onClick={() => void handlePauseRun()} - disabled={runBusy} - title="Pause the current run" - > - <Stop className="h-3 w-3" /> - PAUSE - </button> - )} - {/* Cancel Mission — red with confirmation (VAL-UX-006) */} - {canCancelRun && ( - <button - style={dangerButton()} - onClick={() => { - const msg = LIFECYCLE_ACTIONS.cancel_mission.confirmText; - if (msg && !window.confirm(msg)) return; - void handleCancelRun(); - }} - disabled={runBusy} - title="Cancel the entire mission" - > - <XCircle className="h-3 w-3" /> - CANCEL - </button> - )} - {/* Archive Mission — gray, terminal only (VAL-UX-006) */} - {selectedMission && TERMINAL_MISSION_STATUSES.has(selectedMission.status) && ( - <button - style={outlineButton({ - color: LIFECYCLE_ACTIONS.archive_mission.color, - border: `1px solid ${LIFECYCLE_ACTIONS.archive_mission.color}40`, - background: `${LIFECYCLE_ACTIONS.archive_mission.color}12`, - })} - onClick={() => void handleArchiveMission()} - disabled={cleanupBusy} - title="Archive this mission and clean up lanes" - > - {cleanupBusy ? <SpinnerGap className="h-3 w-3 animate-spin" /> : <Archive className="h-3 w-3" />} - ARCHIVE - </button> - )} - </div> - </div> - ); -} - -/* ────────── Progress Bar ────────── */ - -export function MissionProgressBar({ pct }: { pct: number }) { - return ( - <div className="mt-1.5 h-1 w-full overflow-hidden" style={{ background: COLORS.recessedBg }}> - <div className="h-full transition-all" style={{ width: `${pct}%`, background: COLORS.accent }} /> - </div> - ); -} - -/* ────────── Compact Usage Meter (VAL-USAGE-001, VAL-USAGE-004, VAL-USAGE-005) ────────── */ - -function CompactUsageMeter() { - const active = useMissionPageActive(); - const [snapshot, setSnapshot] = useState<UsageSnapshot | null>(null); - const selectedMission = useMissionsStore((s) => s.selectedMission); - const [perMissionCost, setPerMissionCost] = useState<number>(0); - - useEffect(() => { - if (!active) return; - if (!window.ade?.usage) return; - // Initial load - window.ade.usage.getSnapshot().then(setSnapshot).catch(() => {}); - // Refresh every 2 minutes - const timer = window.setInterval(() => { - window.ade.usage.getSnapshot().then(setSnapshot).catch(() => {}); - }, 120_000); - // Subscribe to live updates - const unsub = window.ade.usage.onUpdate((snap) => setSnapshot(snap)); - return () => { - window.clearInterval(timer); - try { unsub(); } catch { /* ignore */ } - }; - }, [active]); - - // Per-mission cost sourced from missionBudgetService (VAL-USAGE-005 scrutiny fix) - useEffect(() => { - if (!active) return; - if (!selectedMission?.id) { setPerMissionCost(0); return; } - let cancelled = false; - const fetchBudget = () => { - window.ade.orchestrator - .getMissionBudgetStatus({ missionId: selectedMission.id, includeCliTelemetry: false }) - .then((budget) => { - if (!cancelled) setPerMissionCost(budget.mission.usedCostUsd ?? 0); - }) - .catch(() => { - if (!cancelled) setPerMissionCost(0); - }); - }; - fetchBudget(); - const timer = window.setInterval(fetchBudget, 120_000); - return () => { cancelled = true; window.clearInterval(timer); }; - }, [active, selectedMission?.id]); - - if (!snapshot || snapshot.windows.length === 0) return null; - - const claudeWindows = snapshot.windows.filter((w) => w.provider === "claude"); - const codexWindows = snapshot.windows.filter((w) => w.provider === "codex"); - const cursorWindows = snapshot.windows.filter((w) => w.provider === "cursor"); - - // Hide the meter entirely when all windows report 0% and there's no mission cost — it's not useful yet - const allZero = snapshot.windows.every((w) => w.percentUsed === 0) && perMissionCost <= 0; - if (allZero) return null; - - return ( - <div - className="flex items-center gap-2 px-2 py-1 shrink-0" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }} - title="Subscription usage" - > - <Gauge className="h-3 w-3 shrink-0" style={{ color: COLORS.textMuted }} /> - {claudeWindows.map((w) => ( - <UsageWindowBadge key={`claude-${w.windowType}`} provider="Claude" windowType={w.windowType} pct={w.percentUsed} resetsInMs={w.resetsInMs} /> - ))} - {codexWindows.map((w) => ( - <UsageWindowBadge key={`codex-${w.windowType}`} provider="Codex" windowType={w.windowType} pct={w.percentUsed} resetsInMs={w.resetsInMs} /> - ))} - {cursorWindows.map((w) => ( - <UsageWindowBadge key={`cursor-${w.windowType}`} provider="Cursor" windowType={w.windowType} pct={w.percentUsed} resetsInMs={w.resetsInMs} /> - ))} - {/* Per-mission cost from missionBudgetService (VAL-USAGE-005) */} - {perMissionCost > 0 && ( - <span - className="text-[10px]" - style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }} - title={`Mission cost: $${perMissionCost.toFixed(4)}`} - > - ${perMissionCost.toFixed(2)} - </span> - )} - </div> - ); -} - -function UsageWindowBadge({ - provider, - windowType, - pct, - resetsInMs, -}: { - provider: string; - windowType: string; - pct: number; - resetsInMs: number; -}) { - const color = usagePercentColor(pct); - const windowLabel = windowType === "five_hour" ? "5h" : windowType === "monthly" ? "mo" : "wk"; - const resetLabel = resetsInMs > 0 ? formatResetCountdown(resetsInMs) : null; - return ( - <span - className="text-[10px] whitespace-nowrap" - style={{ color, fontFamily: MONO_FONT }} - title={`${provider} ${windowType === "five_hour" ? "5-hour" : windowType === "monthly" ? "monthly" : "weekly"}: ${pct.toFixed(1)}%${resetLabel ? ` — ${resetLabel}` : ""}`} - > - {provider[0]}/{windowLabel} {Math.round(pct)}% - {/* Reset countdown shown inline (VAL-USAGE-004 scrutiny fix) */} - {resetLabel && ( - <span style={{ color: COLORS.textDim, marginLeft: 2, fontSize: 9 }}> - ({resetLabel}) - </span> - )} - </span> - ); -} - -function MissionLaneChip({ laneId, laneName }: { laneId: string | null; laneName: string }): JSX.Element { - const laneColor = useAppStore((s) => { - if (!laneId) return null; - return s.lanes.find((l) => l.id === laneId)?.color ?? null; - }); - return ( - <span style={laneColor ? { color: laneColor } : undefined}> - {laneColor ? ( - <LaneAccentDot lane={{ color: laneColor }} size={6} style={{ marginRight: 4, verticalAlign: "middle" }} /> - ) : ( - <GitBranch className="inline h-3 w-3 mr-0.5" /> - )} - {laneName} - </span> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionLogsTab.tsx b/apps/desktop/src/renderer/components/missions/MissionLogsTab.tsx deleted file mode 100644 index 51bb38781..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionLogsTab.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { MissionLogChannel, MissionLogEntry } from "../../../shared/types"; -import { COLORS, MONO_FONT, outlineButton } from "../lanes/laneDesignTokens"; -import { useMissionPolling } from "./useMissionPolling"; -import { useMissionPageActive } from "./MissionActiveContext"; - -type MissionLogsTabProps = { - missionId: string; - runId: string | null; - focusInterventionId?: string | null; - onFocusHandled?: () => void; -}; - -const CHANNELS: MissionLogChannel[] = [ - "timeline", - "runtime", - "chat", - "outputs", - "reflections", - "retrospectives", - "interventions", -]; - -const CHANNEL_LABELS: Record<MissionLogChannel, string> = { - timeline: "Timeline", - runtime: "Runtime", - chat: "Chat", - outputs: "Outputs", - reflections: "Reflections", - retrospectives: "Retrospectives", - interventions: "Interventions", -}; - -const LEVEL_COLORS: Record<MissionLogEntry["level"], string> = { - info: COLORS.textMuted, - warning: COLORS.warning, - error: COLORS.danger, -}; - -function formatWhen(iso: string): string { - const parsed = Date.parse(iso); - if (!Number.isFinite(parsed)) return iso; - return new Date(parsed).toLocaleString(); -} - -function mergeEntries(existing: MissionLogEntry[], incoming: MissionLogEntry[]): MissionLogEntry[] { - const byId = new Map<string, MissionLogEntry>(); - for (const entry of [...existing, ...incoming]) { - byId.set(entry.id, entry); - } - return [...byId.values()].sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); -} - -export const MissionLogsTab = React.memo(function MissionLogsTab({ - missionId, - runId, - focusInterventionId, - onFocusHandled, -}: MissionLogsTabProps) { - const active = useMissionPageActive(); - const [selectedChannels, setSelectedChannels] = useState<MissionLogChannel[]>(CHANNELS); - const [entries, setEntries] = useState<MissionLogEntry[]>([]); - const [nextCursor, setNextCursor] = useState<string | null>(null); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState<string | null>(null); - const [exportNotice, setExportNotice] = useState<string | null>(null); - const [highlightedInterventionId, setHighlightedInterventionId] = useState<string | null>(focusInterventionId ?? null); - const focusedEntryRef = useRef<HTMLDivElement | null>(null); - - const channelKey = useMemo(() => selectedChannels.join(","), [selectedChannels]); - - const loadLogs = useCallback(async (cursor?: string | null) => { - if (!missionId) return; - const pageLimit = 200; - if (!cursor) { - setLoading(true); - setError(null); - } else { - setLoadingMore(true); - } - try { - const result = await window.ade.orchestrator.getMissionLogs({ - missionId, - runId, - channels: selectedChannels, - cursor: cursor ?? null, - limit: pageLimit, - }); - setTotal(result.total); - setNextCursor(result.nextCursor); - setEntries((prev) => (cursor ? mergeEntries(prev, result.entries) : result.entries)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (!cursor) setLoading(false); - setLoadingMore(false); - } - }, [missionId, runId, selectedChannels]); - - useEffect(() => { - if (!active) return; - void loadLogs(null); - }, [active, loadLogs, channelKey, missionId, runId]); - - useMissionPolling( - () => { - void loadLogs(null); - }, - 3_000, - active && Boolean(missionId), - ); - - useEffect(() => { - if (!active) return; - if (!focusInterventionId) return; - setSelectedChannels(["interventions"]); - setHighlightedInterventionId(focusInterventionId); - onFocusHandled?.(); - }, [active, focusInterventionId, onFocusHandled]); - - useEffect(() => { - focusedEntryRef.current?.scrollIntoView({ - block: "center", - behavior: "smooth", - }); - }, [entries, highlightedInterventionId]); - - const toggleChannel = (channel: MissionLogChannel) => { - setSelectedChannels((prev) => { - const next = prev.includes(channel) - ? prev.filter((entry) => entry !== channel) - : [...prev, channel]; - return next.length > 0 ? next : [channel]; - }); - }; - - const handleExport = async () => { - try { - const result = await window.ade.orchestrator.exportMissionLogs({ - missionId, - runId, - includeArtifacts: true, - }); - setExportNotice(`Exported ${result.manifest.entryCount} entries to ${result.bundlePath}`); - } catch (err) { - setExportNotice(`Export failed: ${err instanceof Error ? err.message : String(err)}`); - } - }; - - return ( - <div className="space-y-3"> - <div className="flex flex-wrap items-center gap-2"> - {CHANNELS.map((channel) => { - const active = selectedChannels.includes(channel); - return ( - <button - key={channel} - type="button" - onClick={() => toggleChannel(channel)} - className="px-2 py-1 text-[10px] font-bold uppercase tracking-[1px]" - style={active - ? { background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 35%, transparent)", color: COLORS.accent, fontFamily: MONO_FONT } - : { background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, color: COLORS.textMuted, fontFamily: MONO_FONT } - } - > - {CHANNEL_LABELS[channel]} - </button> - ); - })} - <button - type="button" - style={outlineButton()} - onClick={() => void loadLogs(null)} - disabled={loading} - > - Refresh - </button> - <button - type="button" - style={outlineButton()} - onClick={() => void handleExport()} - disabled={loading} - > - Export Bundle - </button> - <span className="ml-auto text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {entries.length}/{total} entries - </span> - </div> - - {exportNotice ? ( - <div className="px-2 py-1 text-[10px]" style={{ background: "color-mix(in srgb, var(--color-accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 25%, transparent)", color: COLORS.textPrimary, fontFamily: MONO_FONT }}> - {exportNotice} - </div> - ) : null} - - {error ? ( - <div className="px-2 py-1 text-[10px]" style={{ background: "color-mix(in srgb, var(--color-error) 14%, transparent)", border: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", color: COLORS.danger, fontFamily: MONO_FONT }}> - {error} - </div> - ) : null} - - <div style={{ border: `1px solid ${COLORS.border}`, background: COLORS.cardBg }}> - {loading && entries.length === 0 ? ( - <div className="px-3 py-6 text-xs" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Loading logs... - </div> - ) : entries.length === 0 ? ( - <div className="px-3 py-6 text-xs" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - No log entries yet for the selected channels. - </div> - ) : ( - <div className="max-h-[520px] overflow-y-auto"> - {entries.map((entry) => { - const focused = highlightedInterventionId != null && entry.interventionId === highlightedInterventionId; - return ( - <div - key={entry.id} - ref={focused ? focusedEntryRef : null} - className="px-3 py-2" - style={{ - borderBottom: `1px solid ${COLORS.border}`, - background: focused ? "color-mix(in srgb, var(--color-warning) 10%, transparent)" : "transparent", - }} - > - <div className="flex items-center gap-2 text-[10px]" style={{ fontFamily: MONO_FONT }}> - <span style={{ color: COLORS.textMuted }}>{formatWhen(entry.at)}</span> - <span - className="px-1 py-0.5 uppercase tracking-[1px]" - style={{ background: "color-mix(in srgb, var(--color-accent) 15%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 25%, transparent)", color: COLORS.accent }} - > - {CHANNEL_LABELS[entry.channel]} - </span> - <span style={{ color: LEVEL_COLORS[entry.level] }}>{entry.level.toUpperCase()}</span> - {entry.stepKey ? <span style={{ color: COLORS.textDim }}>step:{entry.stepKey}</span> : null} - {entry.interventionId ? <span style={{ color: COLORS.warning }}>iv:{entry.interventionId.slice(0, 8)}</span> : null} - </div> - <div className="mt-1 text-[11px] font-semibold" style={{ color: COLORS.textPrimary }}> - {entry.title} - </div> - <div className="mt-0.5 text-[11px]" style={{ color: COLORS.textSecondary, whiteSpace: "pre-wrap" }}> - {entry.message} - </div> - </div> - ); - })} - </div> - )} - </div> - - {nextCursor ? ( - <div className="flex justify-center"> - <button - type="button" - style={outlineButton()} - onClick={() => void loadLogs(nextCursor)} - disabled={loadingMore} - > - {loadingMore ? "Loading..." : "Load More"} - </button> - </div> - ) : null} - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionPromptInput.tsx b/apps/desktop/src/renderer/components/missions/MissionPromptInput.tsx deleted file mode 100644 index f97d636b9..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionPromptInput.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React, { useState, useRef, useCallback } from "react"; -import { Paperclip } from "@phosphor-icons/react"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; - -const FOCUS_BORDER_COLOR = COLORS.accent; - -type SlashCommand = { - command: string; - description: string; -}; - -const SLASH_COMMANDS: SlashCommand[] = [ - { command: "/file", description: "Attach file context" }, - { command: "/lane", description: "Reference a lane" }, - { command: "/context", description: "Attach context pack" }, - { command: "/agent", description: "Reference an agent" }, -]; - -type MissionPromptInputProps = { - value: string; - onChange: (value: string) => void; - attachments?: string[]; - onAttachmentsChange?: (attachments: string[]) => void; - placeholder?: string; -}; - -export function MissionPromptInput({ - value, - onChange, - attachments = [], - onAttachmentsChange, - placeholder = "Describe what you want to accomplish...", -}: MissionPromptInputProps) { - const textareaRef = useRef<HTMLTextAreaElement>(null); - const [showSlashMenu, setShowSlashMenu] = useState(false); - const [slashFilter, setSlashFilter] = useState(""); - const [selectedSlashIndex, setSelectedSlashIndex] = useState(0); - const [isFocused, setIsFocused] = useState(false); - - const filteredCommands = SLASH_COMMANDS.filter((cmd) => - cmd.command.toLowerCase().includes(slashFilter.toLowerCase()) - ); - - const handleChange = useCallback( - (e: React.ChangeEvent<HTMLTextAreaElement>) => { - const newValue = e.target.value; - onChange(newValue); - - // Check for slash command trigger - const cursorPos = e.target.selectionStart; - const textBeforeCursor = newValue.slice(0, cursorPos); - const lastSlash = textBeforeCursor.lastIndexOf("/"); - - if (lastSlash >= 0) { - const beforeSlash = textBeforeCursor[lastSlash - 1]; - if (lastSlash === 0 || beforeSlash === " " || beforeSlash === "\n") { - const partial = textBeforeCursor.slice(lastSlash); - if (!partial.includes(" ")) { - setShowSlashMenu(true); - setSlashFilter(partial); - setSelectedSlashIndex(0); - return; - } - } - } - setShowSlashMenu(false); - }, - [onChange] - ); - - const insertSlashCommand = useCallback( - (command: string) => { - const textarea = textareaRef.current; - if (!textarea) return; - const cursorPos = textarea.selectionStart; - const textBeforeCursor = value.slice(0, cursorPos); - const lastSlash = textBeforeCursor.lastIndexOf("/"); - const newValue = value.slice(0, lastSlash) + command + " " + value.slice(cursorPos); - onChange(newValue); - setShowSlashMenu(false); - setTimeout(() => { - textarea.focus(); - const newPos = lastSlash + command.length + 1; - textarea.setSelectionRange(newPos, newPos); - }, 0); - }, - [value, onChange] - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!showSlashMenu) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedSlashIndex((i) => Math.min(i + 1, filteredCommands.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedSlashIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - if (filteredCommands[selectedSlashIndex]) { - insertSlashCommand(filteredCommands[selectedSlashIndex].command); - } - } else if (e.key === "Escape") { - setShowSlashMenu(false); - } - }, - [showSlashMenu, filteredCommands, selectedSlashIndex, insertSlashCommand] - ); - - const handleAttachFile = useCallback(async () => { - try { - const paths = await (window as any).ade?.dialog?.openFile?.({ - multiple: true, - title: "Attach files to mission", - }); - if (paths?.length && onAttachmentsChange) { - onAttachmentsChange([...attachments, ...paths]); - } - } catch { - // dialog cancelled or unavailable - } - }, [attachments, onAttachmentsChange]); - - const removeAttachment = useCallback( - (index: number) => { - if (onAttachmentsChange) { - onAttachmentsChange(attachments.filter((_, i) => i !== index)); - } - }, - [attachments, onAttachmentsChange] - ); - - const inputStyle: React.CSSProperties = { - background: COLORS.recessedBg, - border: `1px solid ${isFocused ? FOCUS_BORDER_COLOR : COLORS.outlineBorder}`, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - borderRadius: 0, - transition: "border-color 0.2s ease", - }; - - return ( - <div className="space-y-1"> - <div className="relative"> - <textarea - ref={textareaRef} - value={value} - onChange={handleChange} - onKeyDown={handleKeyDown} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder={placeholder} - rows={4} - className="w-full px-3 py-2 text-xs outline-none resize-none" - style={inputStyle} - spellCheck - autoCorrect="on" - autoCapitalize="sentences" - autoComplete="on" - /> - - {/* Slash command autocomplete */} - {showSlashMenu && filteredCommands.length > 0 && ( - <div - className="absolute left-0 right-0 z-10 shadow-lg" - style={{ - bottom: "100%", - marginBottom: 4, - background: COLORS.cardBg, - border: `1px solid ${COLORS.border}`, - }} - > - {filteredCommands.map((cmd, i) => ( - <button - key={cmd.command} - type="button" - className="flex items-center gap-2 w-full px-3 py-1.5 text-left" - style={{ - background: i === selectedSlashIndex ? "color-mix(in srgb, var(--color-accent) 18%, transparent)" : "transparent", - border: "none", - cursor: "pointer", - }} - onMouseDown={(e) => { - e.preventDefault(); - insertSlashCommand(cmd.command); - }} - > - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 11, - fontWeight: 700, - color: COLORS.accent, - }} - > - {cmd.command} - </span> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - color: COLORS.textMuted, - }} - > - {cmd.description} - </span> - </button> - ))} - </div> - )} - - {/* Toolbar */} - <div - className="flex items-center gap-1 px-2 py-0.5" - style={{ - background: COLORS.recessedBg, - borderLeft: `1px solid ${isFocused ? FOCUS_BORDER_COLOR : COLORS.outlineBorder}`, - borderRight: `1px solid ${isFocused ? FOCUS_BORDER_COLOR : COLORS.outlineBorder}`, - borderBottom: `1px solid ${isFocused ? FOCUS_BORDER_COLOR : COLORS.outlineBorder}`, - borderTop: "none", - transition: "border-color 0.2s ease", - }} - > - <button - type="button" - onClick={handleAttachFile} - className="p-1 transition-colors" - style={{ background: "none", border: "none", cursor: "pointer", color: COLORS.textMuted }} - title="Attach file" - > - <Paperclip size={14} weight="bold" /> - </button> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - color: COLORS.textDim, - marginLeft: "auto", - }} - > - Type / for commands - </span> - </div> - </div> - - {/* Attachments */} - {attachments.length > 0 && ( - <div className="flex flex-wrap gap-1"> - {attachments.map((path, i) => { - const filename = path.split("/").pop() ?? path; - return ( - <span - key={i} - className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px]" - style={{ - background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", - color: COLORS.accent, - fontFamily: MONO_FONT, - }} - > - {filename} - <button - type="button" - onClick={() => removeAttachment(i)} - style={{ background: "none", border: "none", cursor: "pointer", color: COLORS.accent }} - > - x - </button> - </span> - ); - })} - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionRunPanel.tsx b/apps/desktop/src/renderer/components/missions/MissionRunPanel.tsx deleted file mode 100644 index a7019c4d8..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionRunPanel.tsx +++ /dev/null @@ -1,692 +0,0 @@ -import { useMemo } from "react"; -import type { - MissionRunView, - MissionRunViewWorkerSummary, - MissionRunViewProgressItem, - MissionRunViewHaltReason, - MissionIntervention, - MissionRunViewLatestIntervention, -} from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; -import { relativeWhen } from "../../lib/format"; -import { formatComputerUseKind, summarizeComputerUseProof } from "../../lib/computerUse"; -import { getMissionInterventionOwnerLabel } from "./missionHelpers"; -import { selectCurrentMissionWorkers, sortMissionWorkers } from "./missionWorkerPresentation"; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -type MissionRunPanelProps = { - runView: MissionRunView | null; - interventions?: MissionIntervention[] | null; - loading?: boolean; - onOpenIntervention?: (interventionId: string) => void; - showInterventions?: boolean; - hideInterventionHaltReason?: boolean; -}; - -// --------------------------------------------------------------------------- -// Color maps -// --------------------------------------------------------------------------- - -const STATUS_COLOR: Record<string, string> = { - running: "#3B82F6", - completed: "#22C55E", - failed: "#EF4444", - paused: "#F59E0B", - blocked: "#F59E0B", - not_started: "#6B7280", - canceled: "#6B7280", - starting: "#A78BFA", -}; - -const WORKER_DOT: Record<string, string> = { - active: "#22C55E", - completed: "#6B7280", - failed: "#EF4444", - blocked: "#F59E0B", - idle: "#F59E0B", -}; - -const SEVERITY_COLOR: Record<string, string> = { - info: "#3B82F6", - warning: "#F59E0B", - error: "#EF4444", - success: "#22C55E", -}; - -type RunPanelIntervention = { - id: string; - title: string; - interventionType: string; - status: "open"; - ownerLabel: string | null; - createdAt: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function elapsed(startIso: string | null): string { - if (!startIso) return "--"; - const ms = Math.max(0, Date.now() - Date.parse(startIso)); - const secs = Math.floor(ms / 1000); - const mins = Math.floor(secs / 60); - const hrs = Math.floor(mins / 60); - if (hrs > 0) return `${hrs}h ${mins % 60}m`; - if (mins > 0) return `${mins}m ${secs % 60}s`; - return `${secs}s`; -} - -export function sortWorkers(workers: MissionRunViewWorkerSummary[]): MissionRunViewWorkerSummary[] { - return sortMissionWorkers(workers); -} - -export function selectRecentProgress(items: MissionRunViewProgressItem[], limit = 6): MissionRunViewProgressItem[] { - return items.slice(0, Math.max(0, limit)); -} - -export function selectOpenInterventions(args: { - interventions?: MissionIntervention[] | null; - latestIntervention?: MissionRunViewLatestIntervention | null; -}): RunPanelIntervention[] { - const open = (args.interventions ?? []) - .filter((intervention): intervention is MissionIntervention & { status: "open" } => intervention.status === "open") - .map((intervention) => ({ - id: intervention.id, - title: intervention.title, - interventionType: intervention.interventionType, - status: "open" as const, - ownerLabel: getMissionInterventionOwnerLabel(intervention), - createdAt: intervention.updatedAt || intervention.createdAt, - })); - - if (open.length > 0) { - return [...open].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); - } - - if (args.latestIntervention?.status === "open") { - return [{ - id: args.latestIntervention.id, - title: args.latestIntervention.title, - interventionType: args.latestIntervention.interventionType, - status: "open", - ownerLabel: args.latestIntervention.ownerLabel ?? null, - createdAt: args.latestIntervention.createdAt, - }]; - } - - return []; -} - -// --------------------------------------------------------------------------- -// Section styles -// --------------------------------------------------------------------------- - -const sectionStyle: React.CSSProperties = { - background: COLORS.cardBg, - border: `1px solid ${COLORS.border}`, - padding: "8px 10px", -}; - -const sectionLabelStyle: React.CSSProperties = { - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase", - letterSpacing: "1px", - color: COLORS.textDim, - marginBottom: 4, -}; - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -function StatusBadge({ status }: { status: string }) { - const color = STATUS_COLOR[status] ?? COLORS.textMuted; - return ( - <span - style={{ - display: "inline-flex", - alignItems: "center", - padding: "1px 6px", - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase", - letterSpacing: "0.8px", - color, - background: `${color}18`, - border: `1px solid ${color}30`, - }} - > - {status.replace(/_/g, " ")} - </span> - ); -} - -function HaltBanner({ halt }: { halt: MissionRunViewHaltReason }) { - const color = halt.severity === "error" ? COLORS.danger : COLORS.warning; - return ( - <div - style={{ - background: `${color}0C`, - borderLeft: `3px solid ${color}`, - padding: "6px 10px", - }} - > - <div - style={{ - fontSize: 11, - fontWeight: 700, - fontFamily: SANS_FONT, - color, - marginBottom: 2, - }} - > - {halt.title} - </div> - <div - style={{ - fontSize: 10, - fontFamily: MONO_FONT, - color: COLORS.textSecondary, - lineHeight: "1.4", - }} - > - {halt.detail} - </div> - </div> - ); -} - -function WorkerRow({ worker }: { worker: MissionRunViewWorkerSummary }) { - const dotColor = WORKER_DOT[worker.status] ?? COLORS.textMuted; - return ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "2px 0", - fontSize: 10, - fontFamily: MONO_FONT, - }} - > - <span - style={{ - width: 6, - height: 6, - borderRadius: "50%", - background: dotColor, - flexShrink: 0, - }} - /> - <span - style={{ - color: COLORS.textPrimary, - flex: 1, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }} - > - {worker.stepTitle ?? "idle"} - </span> - {worker.executorKind && ( - <span style={{ color: COLORS.textDim, flexShrink: 0 }}> - {worker.executorKind} - </span> - )} - <span - style={{ - color: WORKER_DOT[worker.status] ?? COLORS.textMuted, - flexShrink: 0, - fontSize: 9, - fontWeight: 600, - textTransform: "uppercase", - }} - > - {worker.state} - </span> - </div> - ); -} - -function ProgressEntry({ item }: { item: MissionRunViewProgressItem }) { - const color = SEVERITY_COLOR[item.severity] ?? COLORS.textMuted; - return ( - <div - style={{ - display: "flex", - alignItems: "flex-start", - gap: 6, - padding: "1px 0", - fontSize: 10, - fontFamily: MONO_FONT, - lineHeight: "1.35", - }} - > - <span - style={{ - color: COLORS.textDim, - flexShrink: 0, - minWidth: 42, - textAlign: "right", - fontSize: 9, - }} - > - {relativeWhen(item.at)} - </span> - <span - style={{ - width: 4, - height: 4, - borderRadius: "50%", - background: color, - flexShrink: 0, - marginTop: 4, - }} - /> - <span - style={{ - color: COLORS.textSecondary, - whiteSpace: "normal", - wordBreak: "break-word", - }} - > - {item.title} - </span> - </div> - ); -} - -function ComputerUsePanel({ runView }: { runView: MissionRunView }) { - const snapshot = runView.computerUse; - if (!snapshot) return null; - - return ( - <div style={sectionStyle}> - <div className="flex flex-wrap items-center justify-between gap-2"> - <div style={sectionLabelStyle}>Proof</div> - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textPrimary }}> - {snapshot.summary} - </div> - <div className="mt-2 flex flex-wrap gap-3 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>{summarizeComputerUseProof(snapshot)}</span> - <span> - Backend: {snapshot.activeBackend ? `${snapshot.activeBackend.name} (${snapshot.activeBackend.source})` : "not selected"} - </span> - </div> - {snapshot.activity.length > 0 ? ( - <div className="mt-3 space-y-1"> - {snapshot.activity.slice(0, 4).map((item) => ( - <div key={item.id} className="flex items-start gap-2 text-[10px]" style={{ fontFamily: MONO_FONT }}> - <span style={{ color: SEVERITY_COLOR[item.severity] ?? COLORS.textMuted, minWidth: 50 }}> - {relativeWhen(item.at)} - </span> - <span style={{ color: COLORS.textSecondary }}>{item.title}</span> - </div> - ))} - </div> - ) : null} - {snapshot.recentArtifacts.length > 0 ? ( - <div className="mt-3 flex flex-wrap gap-2 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {snapshot.recentArtifacts.slice(0, 4).map((artifact) => ( - <span key={artifact.id}> - {formatComputerUseKind(artifact.kind)} via {artifact.backendName} - </span> - ))} - </div> - ) : null} - </div> - ); -} - -function InterventionBanner({ - intervention, - onOpen, -}: { - intervention: RunPanelIntervention; - onOpen?: (id: string) => void; -}) { - return ( - <div - style={{ - background: "color-mix(in srgb, var(--color-warning) 5%, transparent)", - borderLeft: `3px solid ${COLORS.warning}`, - padding: "6px 10px", - display: "flex", - alignItems: "center", - gap: 8, - }} - > - <div style={{ flex: 1, minWidth: 0 }}> - <div - style={{ - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase", - letterSpacing: "0.8px", - color: COLORS.warning, - marginBottom: 2, - }} - > - {intervention.ownerLabel ?? intervention.interventionType.replace(/_/g, " ")} - </div> - <div - style={{ - fontSize: 11, - fontFamily: SANS_FONT, - color: COLORS.textPrimary, - whiteSpace: "normal", - wordBreak: "break-word", - }} - > - {intervention.title} - </div> - </div> - {onOpen && ( - <button - type="button" - onClick={() => onOpen(intervention.id)} - style={{ - flexShrink: 0, - padding: "3px 10px", - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase", - letterSpacing: "1px", - color: COLORS.warning, - background: "color-mix(in srgb, var(--color-warning) 14%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-warning) 35%, transparent)", - cursor: "pointer", - }} - > - OPEN - </button> - )} - </div> - ); -} - -// --------------------------------------------------------------------------- -// Main Component -// --------------------------------------------------------------------------- - -export function MissionRunPanel({ - runView, - interventions = null, - loading = false, - onOpenIntervention, - showInterventions = true, - hideInterventionHaltReason = false, -}: MissionRunPanelProps) { - const sortedWorkers = useMemo( - () => (runView ? sortWorkers(selectCurrentMissionWorkers(runView.workers)) : []), - [runView], - ); - - const recentProgress = useMemo( - () => (runView ? selectRecentProgress(runView.progressLog) : []), - [runView], - ); - - const openInterventions = useMemo( - () => selectOpenInterventions({ interventions, latestIntervention: runView?.latestIntervention ?? null }), - [interventions, runView?.latestIntervention], - ); - - // Null state - if (!runView) { - if (loading) { - return ( - <div style={{ fontSize: 11, color: COLORS.textDim, padding: "8px 0" }}> - Loading run view... - </div> - ); - } - return null; - } - - const { lifecycle, active, coordinator, haltReason } = runView; - const statusColor = STATUS_COLOR[lifecycle.displayStatus] ?? COLORS.textMuted; - const shouldRenderHaltReason = !(hideInterventionHaltReason && haltReason?.source === "intervention"); - - return ( - <div style={{ display: "flex", flexDirection: "column", gap: 6 }}> - {/* ── 1. Status Bar ── */} - <div - style={{ - ...sectionStyle, - display: "flex", - alignItems: "center", - gap: 8, - flexWrap: "wrap", - }} - > - <StatusBadge status={lifecycle.displayStatus} /> - - <span - style={{ - fontSize: 11, - fontFamily: SANS_FONT, - fontWeight: 600, - color: COLORS.textPrimary, - flex: 1, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - minWidth: 0, - }} - > - {lifecycle.summary} - </span> - - {active.phaseName && ( - <span - style={{ - fontSize: 9, - fontFamily: MONO_FONT, - fontWeight: 600, - color: COLORS.accent, - textTransform: "uppercase", - letterSpacing: "0.8px", - flexShrink: 0, - }} - > - {active.phaseName} - </span> - )} - - <span - style={{ - fontSize: 10, - fontFamily: MONO_FONT, - color: COLORS.textDim, - flexShrink: 0, - }} - > - {elapsed(lifecycle.startedAt)} - </span> - </div> - - {/* ── 2. Halt / Blocker Banner ── */} - {haltReason && shouldRenderHaltReason && <HaltBanner halt={haltReason} />} - {!haltReason && lifecycle.displayStatus === "blocked" && ( - <HaltBanner - halt={{ - title: "Mission blocked", - detail: lifecycle.summary || "The mission is blocked and requires attention.", - severity: "warning", - source: "mission", - }} - /> - )} - {!haltReason && lifecycle.displayStatus === "paused" && ( - <HaltBanner - halt={{ - title: "Run paused", - detail: lifecycle.summary || "The orchestrator run has been paused.", - severity: "warning", - source: "run", - }} - /> - )} - {!haltReason - && lifecycle.displayStatus !== "blocked" - && lifecycle.displayStatus !== "paused" - && showInterventions - && openInterventions.length > 0 - && ( - <HaltBanner - halt={{ - title: "Intervention required", - detail: `${openInterventions.length} open intervention${openInterventions.length === 1 ? "" : "s"} awaiting response.`, - severity: "warning", - source: "intervention", - }} - /> - )} - - {/* ── 3. Active Step ── */} - {active.stepTitle && ( - <div - style={{ - ...sectionStyle, - display: "flex", - alignItems: "center", - gap: 6, - }} - > - <span - style={{ - width: 5, - height: 5, - borderRadius: "50%", - background: statusColor, - flexShrink: 0, - }} - /> - <span - style={{ - fontSize: 11, - fontFamily: SANS_FONT, - fontWeight: 600, - color: COLORS.textPrimary, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - flex: 1, - }} - > - {active.stepTitle} - </span> - {active.featureLabel && ( - <span - style={{ - fontSize: 9, - fontFamily: MONO_FONT, - color: COLORS.textMuted, - flexShrink: 0, - }} - > - {active.featureLabel} - </span> - )} - </div> - )} - - {/* ── 4. Coordinator Status ── */} - {coordinator.available != null && ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "4px 10px", - fontSize: 10, - fontFamily: MONO_FONT, - color: COLORS.textDim, - }} - > - <span> - Coordinator:{" "} - <span - style={{ - color: coordinator.available ? COLORS.success : COLORS.textMuted, - fontWeight: 600, - }} - > - {coordinator.available ? "online" : "offline"} - </span> - </span> - {coordinator.mode && ( - <span style={{ color: COLORS.textMuted }}> - {coordinator.mode.replace(/_/g, " ")} - </span> - )} - {coordinator.summary && ( - <span - style={{ - color: COLORS.textSecondary, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - flex: 1, - }} - > - {coordinator.summary} - </span> - )} - </div> - )} - - <ComputerUsePanel runView={runView} /> - - {/* ── 5. Workers ── */} - {sortedWorkers.length > 0 && ( - <div style={sectionStyle}> - <div style={sectionLabelStyle}> - Workers ({sortedWorkers.length}) - </div> - {sortedWorkers.map((worker, idx) => ( - <WorkerRow - key={worker.attemptId ?? worker.sessionId ?? `w-${idx}`} - worker={worker} - /> - ))} - </div> - )} - - {/* ── 6. Open Interventions ── */} - {showInterventions && openInterventions.length > 0 && ( - <div style={{ display: "flex", flexDirection: "column", gap: 6 }}> - {openInterventions.map((intervention) => ( - <InterventionBanner - key={intervention.id} - intervention={intervention} - onOpen={onOpenIntervention} - /> - ))} - </div> - )} - - {/* ── 7. Progress Log ── */} - {recentProgress.length > 0 && ( - <div style={sectionStyle}> - <div style={sectionLabelStyle}>Recent Activity</div> - {recentProgress.map((item) => ( - <ProgressEntry key={item.id} item={item} /> - ))} - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionSettingsDialog.tsx b/apps/desktop/src/renderer/components/missions/MissionSettingsDialog.tsx deleted file mode 100644 index 05d67fde8..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionSettingsDialog.tsx +++ /dev/null @@ -1,581 +0,0 @@ -import React, { useState, useCallback, useEffect, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; -import { - GearSix, - X, -} from "@phosphor-icons/react"; -import { motion } from "motion/react"; -import type { PhaseProfile, PhaseCard } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT, primaryButton, outlineButton, dangerButton } from "../lanes/laneDesignTokens"; -import { ConfirmDialog, PromptDialog, useConfirmDialog, usePromptDialog } from "../shared/InlineDialogs"; -import { - type MissionSettingsDraft, - toTeammatePlanMode, - DEFAULT_SMART_BUDGET, -} from "./missionHelpers"; -import { SmartBudgetPanel } from "./SmartBudgetPanel"; -import { PhaseCardEditor } from "./PhaseCardEditor"; -import { WorkerPermissionsEditor } from "./WorkerPermissionsEditor"; -import { ModelSelector } from "./ModelSelector"; -import { createBuiltInMissionPhaseCards, createBuiltInMissionPhaseProfiles } from "./missionPhaseDefaults"; -import { getCachedPhaseProfiles, setCachedPhaseProfiles } from "./missionDialogDataCache"; - -function PhaseProfileCard({ - profile, - phaseBusy, - setPhaseBusy, - setPhaseNotice, - setPhaseError, - refreshPhaseProfiles, -}: { - profile: PhaseProfile; - phaseBusy: boolean; - setPhaseBusy: (v: boolean) => void; - setPhaseNotice: (v: string | null) => void; - setPhaseError: (v: string | null) => void; - refreshPhaseProfiles: () => Promise<void>; -}) { - const [expanded, setExpanded] = useState(false); - const [editName, setEditName] = useState(profile.name); - const [editDescription, setEditDescription] = useState(profile.description); - const [editPhases, setEditPhases] = useState<PhaseCard[]>(profile.phases); - const [dirty, setDirty] = useState(false); - const [expandedPhaseIds, setExpandedPhaseIds] = useState<Record<string, boolean>>({}); - const deleteConfirm = useConfirmDialog(); - - const settingsInputStyle: React.CSSProperties = { height: 28, width: "100%", background: COLORS.recessedBg, border: `1px solid ${COLORS.outlineBorder}`, padding: "0 8px", fontSize: 11, color: COLORS.textPrimary, fontFamily: MONO_FONT, borderRadius: 0, outline: "none" }; - const settingsLabelStyle: React.CSSProperties = { fontSize: 9, fontWeight: 700, fontFamily: MONO_FONT, textTransform: "uppercase" as const, letterSpacing: "1px", color: COLORS.textMuted }; - - const isReadOnly = profile.isBuiltIn; - - return ( - <div className="p-2" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <ConfirmDialog state={deleteConfirm.state} onClose={deleteConfirm.close} /> - <div className="flex items-center gap-2"> - <div className="min-w-0 flex-1"> - <div className="truncate text-xs font-semibold" style={{ color: COLORS.textPrimary }}> - {profile.isBuiltIn ? <span style={{ color: COLORS.accent, marginRight: 4 }}>{"\u25CF"}</span> : null} - {profile.name} - {profile.isDefault ? <span style={{ fontSize: 9, fontWeight: 600, color: "#22C55E", marginLeft: 4, fontFamily: MONO_FONT }}>DEFAULT</span> : null} - </div> - <div className="truncate text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {profile.description || profile.phases.map((phase) => phase.name).join(" \u2192 ")} - </div> - <div className="text-[9px] mt-0.5" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - {profile.phases.length} phase{profile.phases.length !== 1 ? "s" : ""} · {profile.isBuiltIn ? "Built-in" : "Custom"} - </div> - </div> - <button - style={outlineButton()} - disabled={phaseBusy} - onClick={() => { - if (!expanded) { - setEditName(profile.name); - setEditDescription(profile.description); - setEditPhases(profile.phases); - setDirty(false); - setExpandedPhaseIds({}); - } - setExpanded(!expanded); - }} - > - {expanded ? "HIDE" : isReadOnly ? "VIEW" : "EDIT"} - </button> - <button - style={outlineButton()} - disabled={phaseBusy} - onClick={async () => { - setPhaseBusy(true); - try { - await window.ade.missions.clonePhaseProfile({ profileId: profile.id }); - await refreshPhaseProfiles(); - setPhaseNotice("Profile cloned."); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } finally { - setPhaseBusy(false); - } - }} - > - CLONE - </button> - <button - style={outlineButton()} - disabled={phaseBusy} - onClick={async () => { - setPhaseBusy(true); - try { - const exported = await window.ade.missions.exportPhaseProfile({ profileId: profile.id }); - setPhaseNotice(exported.savedPath ? `Exported: ${exported.savedPath}` : "Profile exported."); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } finally { - setPhaseBusy(false); - } - }} - > - EXPORT - </button> - {!profile.isBuiltIn ? ( - <button - style={dangerButton()} - disabled={phaseBusy} - onClick={async () => { - const ok = await deleteConfirm.confirmAsync({ - title: "Delete Phase Profile", - message: `Delete phase profile "${profile.name}"?`, - confirmLabel: "DELETE", - danger: true, - }); - if (!ok) return; - setPhaseBusy(true); - try { - await window.ade.missions.deletePhaseProfile({ profileId: profile.id }); - await refreshPhaseProfiles(); - setPhaseNotice("Profile deleted."); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } finally { - setPhaseBusy(false); - } - }} - > - DELETE - </button> - ) : null} - </div> - - {expanded && ( - <div className="mt-3 space-y-3 pt-2" style={{ borderTop: `1px solid ${COLORS.border}` }}> - {!isReadOnly && ( - <div className="grid grid-cols-1 gap-2 md:grid-cols-2"> - <label className="space-y-0.5"> - <span style={settingsLabelStyle}>PROFILE NAME</span> - <input - value={editName} - onChange={(e) => { setEditName(e.target.value); setDirty(true); }} - style={settingsInputStyle} - /> - </label> - <label className="space-y-0.5"> - <span style={settingsLabelStyle}>DESCRIPTION</span> - <input - value={editDescription} - onChange={(e) => { setEditDescription(e.target.value); setDirty(true); }} - placeholder="Describe this profile" - style={settingsInputStyle} - /> - </label> - </div> - )} - - <div className="space-y-1"> - <span style={settingsLabelStyle}>PHASES ({editPhases.length})</span> - {editPhases.map((phase, idx) => ( - <PhaseCardEditor - key={phase.id} - phase={phase} - index={idx} - totalCount={editPhases.length} - expanded={expandedPhaseIds[phase.id] === true} - readOnly={isReadOnly} - onToggleExpand={() => setExpandedPhaseIds((prev) => ({ ...prev, [phase.id]: !prev[phase.id] }))} - onUpdate={(updated) => { - setEditPhases((prev) => prev.map((p) => p.id === updated.id ? updated : p)); - setDirty(true); - }} - onMoveUp={() => { - if (idx === 0) return; - setEditPhases((prev) => { - const next = [...prev]; - const moved = next[idx]!; - next.splice(idx, 1); - next.splice(idx - 1, 0, moved); - return next.map((p, i) => ({ ...p, position: i })); - }); - setDirty(true); - }} - onMoveDown={() => { - setEditPhases((prev) => { - if (idx >= prev.length - 1) return prev; - const next = [...prev]; - const moved = next[idx]!; - next.splice(idx, 1); - next.splice(idx + 1, 0, moved); - return next.map((p, i) => ({ ...p, position: i })); - }); - setDirty(true); - }} - onRemove={phase.isCustom && !isReadOnly ? () => { - setEditPhases((prev) => prev.filter((p) => p.id !== phase.id).map((p, i) => ({ ...p, position: i }))); - setDirty(true); - } : undefined} - labelStyle={settingsLabelStyle} - inputStyle={settingsInputStyle} - /> - ))} - - {!isReadOnly && ( - <button - type="button" - style={outlineButton()} - onClick={() => { - const now = new Date().toISOString(); - setEditPhases((prev) => [ - ...prev, - { - id: `custom:${Date.now()}`, - phaseKey: `custom_${prev.length + 1}`, - name: `Custom Phase ${prev.length + 1}`, - description: "", - instructions: "", - model: { provider: "claude", modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: false, - isCustom: true, - position: prev.length, - createdAt: now, - updatedAt: now, - } - ]); - setDirty(true); - }} - > - + ADD PHASE - </button> - )} - </div> - - {dirty && !isReadOnly && ( - <div className="flex items-center gap-2 pt-1"> - <button - style={primaryButton()} - disabled={phaseBusy || !editName.trim()} - onClick={async () => { - setPhaseBusy(true); - try { - await window.ade.missions.savePhaseProfile({ - profile: { - id: profile.id, - name: editName.trim(), - description: editDescription.trim(), - phases: editPhases, - } - }); - await refreshPhaseProfiles(); - setPhaseNotice("Profile saved."); - setDirty(false); - setExpanded(false); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } finally { - setPhaseBusy(false); - } - }} - > - SAVE - </button> - <button - style={outlineButton()} - onClick={() => { - setEditName(profile.name); - setEditDescription(profile.description); - setEditPhases(profile.phases); - setDirty(false); - }} - > - REVERT - </button> - </div> - )} - </div> - )} - </div> - ); -} - -export function MissionSettingsDialog({ - open, - onClose, - draft, - onDraftChange, - onSave, - busy, - error, - notice -}: { - open: boolean; - onClose: () => void; - draft: MissionSettingsDraft; - onDraftChange: (update: Partial<MissionSettingsDraft>) => void; - onSave: () => void; - busy: boolean; - error: string | null; - notice: string | null; -}) { - const navigate = useNavigate(); - const openAiProvidersSettings = useCallback(() => { - navigate("/settings?tab=ai#ai-providers"); - }, [navigate]); - const builtInPhaseCards = useMemo(() => createBuiltInMissionPhaseCards(), []); - const builtInPhaseProfiles = useMemo( - () => createBuiltInMissionPhaseProfiles(builtInPhaseCards), - [builtInPhaseCards] - ); - const [hasMountedBody, setHasMountedBody] = useState(open); - const [phaseProfiles, setPhaseProfiles] = useState<PhaseProfile[]>(() => { - const cachedProfiles = getCachedPhaseProfiles(); - return cachedProfiles?.length ? cachedProfiles : builtInPhaseProfiles; - }); - const [phaseBusy, setPhaseBusy] = useState(false); - const [phaseNotice, setPhaseNotice] = useState<string | null>(null); - const [phaseError, setPhaseError] = useState<string | null>(null); - const createPrompt = usePromptDialog(); - const importPrompt = usePromptDialog(); - - const refreshPhaseProfiles = useCallback(async () => { - try { - const profiles = await window.ade.missions.listPhaseProfiles({}); - const nextProfiles = profiles.length > 0 ? profiles : builtInPhaseProfiles; - setCachedPhaseProfiles(nextProfiles); - setPhaseProfiles(nextProfiles); - setPhaseError(null); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } - }, [builtInPhaseProfiles]); - - useEffect(() => { - if (hasMountedBody) return; - if (open) { - setHasMountedBody(true); - return; - } - const timeoutId = window.setTimeout(() => { - setHasMountedBody(true); - }, 300); - return () => { - window.clearTimeout(timeoutId); - }; - }, [hasMountedBody, open]); - - useEffect(() => { - if (!open) return; - setPhaseNotice(null); - const cachedProfiles = getCachedPhaseProfiles(); - setPhaseProfiles(cachedProfiles?.length ? cachedProfiles : builtInPhaseProfiles); - }, [open, builtInPhaseProfiles]); - - const defaultProfile = phaseProfiles.find((p) => p.isDefault) ?? phaseProfiles[0] ?? null; - - const shouldRenderBody = hasMountedBody || open; - if (!shouldRenderBody) return null; - - const settingsInputStyle: React.CSSProperties = { height: 32, width: "100%", background: COLORS.recessedBg, border: `1px solid ${COLORS.outlineBorder}`, padding: "0 8px", fontSize: 12, color: COLORS.textPrimary, fontFamily: MONO_FONT, borderRadius: 0, outline: "none" }; - const settingsLabelStyle: React.CSSProperties = { fontSize: 10, fontWeight: 700, fontFamily: MONO_FONT, textTransform: "uppercase" as const, letterSpacing: "1px", color: COLORS.textMuted }; - - return ( - <div - aria-hidden={!open} - className={`fixed inset-0 z-50 flex items-center justify-center bg-black/70 transition-opacity duration-150 ${ - open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" - }`} - style={{ visibility: open ? "visible" : "hidden" }} - onClick={onClose} - > - <PromptDialog state={createPrompt.state} onClose={createPrompt.close} /> - <PromptDialog state={importPrompt.state} onClose={importPrompt.close} /> - <motion.div - initial={false} - animate={{ - opacity: open ? 1 : 0, - scale: open ? 1 : 0.98, - transition: { duration: open ? 0.15 : 0.1 }, - }} - className="flex w-[min(96vw,1100px)] max-h-[92vh] flex-col shadow-2xl" - style={{ - background: COLORS.cardBgSolid, - border: `1px solid ${COLORS.outlineBorder}`, - boxShadow: "0 28px 80px -36px rgba(0,0,0,0.82)", - }} - onClick={(event) => event.stopPropagation()} - > - <div - className="flex items-center justify-between px-5 h-14 shrink-0" - style={{ - background: "#120F1A", - borderBottom: `1px solid ${COLORS.border}`, - }} - > - <div className="flex items-center gap-2"> - <GearSix className="h-4 w-4" style={{ color: COLORS.accent }} /> - <h2 className="text-sm font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}>MISSION SETTINGS</h2> - </div> - <button onClick={onClose} className="transition-colors" style={{ color: COLORS.textMuted }}> - <X className="h-4 w-4" /> - </button> - </div> - - <div className="min-h-0 overflow-y-auto px-5 py-4 space-y-4"> - {notice ? <div className="px-3 py-2 text-xs" style={{ border: "1px solid color-mix(in srgb, var(--color-success) 30%, transparent)", background: "color-mix(in srgb, var(--color-success) 18%, transparent)", color: COLORS.success }}>{notice}</div> : null} - {error ? <div className="px-3 py-2 text-xs" style={{ border: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", background: "color-mix(in srgb, var(--color-error) 18%, transparent)", color: COLORS.danger }}>{error}</div> : null} - - <div className="p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-xs font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}>MISSION DEFAULTS</div> - <div className="mt-3"> - <div className="px-2 py-1.5 text-[10px]" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Execution policy is derived from your Phase Profiles below. Customize phases to control planning, testing, validation, and review behavior. - </div> - </div> - <div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2"> - <div className="text-xs"> - <div style={settingsLabelStyle}>DEFAULT ORCHESTRATOR MODEL</div> - <div className="mt-1"> - <ModelSelector - value={draft.defaultOrchestratorModel} - onChange={(config) => onDraftChange({ defaultOrchestratorModel: config })} - compact - onOpenAiSettings={openAiProvidersSettings} - /> - </div> - </div> - <label className="text-xs"> - <div style={settingsLabelStyle}>TEAMMATE PLAN MODE</div> - <select - style={settingsInputStyle} - value={draft.teammatePlanMode} - onChange={(e) => onDraftChange({ teammatePlanMode: toTeammatePlanMode(e.target.value) })} - > - <option value="auto">Auto</option> - <option value="off">Off</option> - <option value="required">Required</option> - </select> - </label> - </div> - </div> - - <div className="p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <WorkerPermissionsEditor - orchestratorModelId={draft.defaultOrchestratorModel?.modelId} - phases={defaultProfile?.phases ?? []} - permissionConfig={draft.permissionConfig} - onPermissionChange={(next) => onDraftChange({ permissionConfig: next })} - /> - </div> - - {/* Smart Token Budget — configurable from mission settings (scrutiny fix) */} - <div className="p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <SmartBudgetPanel - value={draft.smartBudget ?? DEFAULT_SMART_BUDGET} - onChange={(config) => onDraftChange({ smartBudget: config })} - /> - </div> - - <div className="p-3" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between"> - <div className="text-xs font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}> - PHASE PROFILES - </div> - <div className="flex items-center gap-2"> - <button - style={outlineButton()} - disabled={phaseBusy} - onClick={async () => { - const name = await createPrompt.promptAsync({ - title: "Create Phase Profile", - message: "Enter a name for the new phase profile.", - defaultValue: "Custom Profile", - confirmLabel: "CREATE", - }); - if (!name || !name.trim()) return; - const fallback = phaseProfiles.find((profile) => profile.isDefault) ?? phaseProfiles[0] ?? null; - const phases = fallback?.phases ?? []; - if (!phases.length) return; - setPhaseBusy(true); - try { - await window.ade.missions.savePhaseProfile({ - profile: { - name: name.trim(), - description: "Created from Mission Settings", - phases - } - }); - await refreshPhaseProfiles(); - setPhaseNotice("Phase profile created."); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } finally { - setPhaseBusy(false); - } - }} - > - + CREATE - </button> - <button - style={outlineButton()} - disabled={phaseBusy} - onClick={async () => { - const filePath = await importPrompt.promptAsync({ - title: "Import Phase Profile", - message: "Enter the path to the phase profile JSON file.", - placeholder: "/path/to/profile.json", - confirmLabel: "IMPORT", - }); - if (!filePath || !filePath.trim()) return; - setPhaseBusy(true); - try { - await window.ade.missions.importPhaseProfile({ filePath: filePath.trim() }); - await refreshPhaseProfiles(); - setPhaseNotice("Profile imported."); - } catch (err) { - setPhaseError(err instanceof Error ? err.message : String(err)); - } finally { - setPhaseBusy(false); - } - }} - > - IMPORT - </button> - </div> - </div> - - {phaseNotice ? ( - <div className="mt-2 px-2 py-1.5 text-[10px]" style={{ border: "1px solid color-mix(in srgb, var(--color-success) 30%, transparent)", background: "color-mix(in srgb, var(--color-success) 15%, transparent)", color: COLORS.success }}> - {phaseNotice} - </div> - ) : null} - {phaseError ? ( - <div className="mt-2 px-2 py-1.5 text-[10px]" style={{ border: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", background: "color-mix(in srgb, var(--color-error) 15%, transparent)", color: COLORS.danger }}> - {phaseError} - </div> - ) : null} - - <div className="mt-3 space-y-2"> - {phaseProfiles.map((profile) => ( - <PhaseProfileCard - key={profile.id} - profile={profile} - phaseBusy={phaseBusy} - setPhaseBusy={setPhaseBusy} - setPhaseNotice={setPhaseNotice} - setPhaseError={setPhaseError} - refreshPhaseProfiles={refreshPhaseProfiles} - /> - ))} - </div> - </div> - </div> - - <div className="flex items-center justify-end gap-2 px-5 py-3 shrink-0" style={{ borderTop: `1px solid ${COLORS.border}` }}> - <button style={outlineButton()} onClick={onClose} disabled={busy}>CLOSE</button> - <button style={primaryButton()} onClick={onSave} disabled={busy}> - {busy ? "SAVING..." : "SAVE SETTINGS"} - </button> - </div> - </motion.div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionSidebar.tsx b/apps/desktop/src/renderer/components/missions/MissionSidebar.tsx deleted file mode 100644 index 387146556..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionSidebar.tsx +++ /dev/null @@ -1,615 +0,0 @@ -import { useMemo, useRef, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react"; -import { - Rocket, - SpinnerGap, - ArrowsClockwise, - MagnifyingGlass, - Plus, - GearSix, - List, - Kanban, -} from "@phosphor-icons/react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useShallow } from "zustand/react/shallow"; -import type { MissionRunView, MissionRunViewDisplayStatus, MissionSummary } from "../../../shared/types"; -import { cn } from "../ui/cn"; -import { COLORS, MONO_FONT, SANS_FONT, primaryButton } from "../lanes/laneDesignTokens"; -import { relativeWhen } from "../../lib/format"; -import { - MISSION_BOARD_COLUMNS, - getMissionStatusBadgeConfig, - isMissionVisiblyActive, - type MissionListViewMode, -} from "./missionHelpers"; -import { buildMissionStateNarrative } from "./missionFeedPresentation"; -import { useMissionsStore } from "./useMissionsStore"; -import { openMissionCreateDialog } from "./missionCreateDialogStore"; - -const selectSidebarState = (s: ReturnType<typeof useMissionsStore.getState>) => ({ - missions: s.missions, - selectedMissionId: s.selectedMissionId, - selectedMission: s.selectedMission, - searchFilter: s.searchFilter, - missionListView: s.missionListView, - refreshing: s.refreshing, - missionSettingsSnapshot: s.missionSettingsSnapshot, -}); - -/* ════════════════════ MISSION SIDEBAR ════════════════════ */ - -export function MissionSidebar({ runView }: { runView: MissionRunView | null }) { - const { - missions, - selectedMissionId, - selectedMission, - searchFilter, - missionListView, - refreshing, - missionSettingsSnapshot, - } = useMissionsStore(useShallow(selectSidebarState)); - - const setSelectedMissionId = useMissionsStore((s) => s.setSelectedMissionId); - const setSearchFilter = useMissionsStore((s) => s.setSearchFilter); - const setMissionListView = useMissionsStore((s) => s.setMissionListView); - const setMissionContextMenu = useMissionsStore((s) => s.setMissionContextMenu); - const setMissionSettingsOpen = useMissionsStore((s) => s.setMissionSettingsOpen); - const setMissionSettingsNotice = useMissionsStore((s) => s.setMissionSettingsNotice); - const setMissionSettingsError = useMissionsStore((s) => s.setMissionSettingsError); - const refreshMissionList = useMissionsStore((s) => s.refreshMissionList); - const loadMissionSettings = useMissionsStore((s) => s.loadMissionSettings); - - const selectedDisplayStatus = runView?.lifecycle.displayStatus ?? null; - const selectedMissionNarrative = useMemo(() => buildMissionStateNarrative(runView), [runView]); - - const filteredMissions = useMemo(() => { - if (!searchFilter.trim()) return missions; - const q = searchFilter.toLowerCase(); - return missions.filter( - (m) => m.title.toLowerCase().includes(q) || m.status.includes(q), - ); - }, [missions, searchFilter]); - - const handleMissionContextMenu = (mission: MissionSummary, event: ReactMouseEvent<HTMLButtonElement>) => { - event.preventDefault(); - setSelectedMissionId(mission.id); - setMissionContextMenu({ - mission, - x: event.clientX, - y: event.clientY, - }); - }; - - return ( - <div - className="flex h-full w-full flex-col" - style={{ background: COLORS.cardBg, borderRight: `1px solid ${COLORS.border}` }} - > - {/* Header */} - <div - className="flex items-center justify-between shrink-0 h-12 px-3" - style={{ borderBottom: `1px solid ${COLORS.border}` }} - > - <div className="flex items-center gap-2"> - <Rocket size={16} weight="bold" style={{ color: COLORS.accent }} /> - <span - className="text-[14px] font-bold tracking-[-0.2px]" - style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }} - > - MISSIONS - </span> - <span - className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-[1px]" - style={{ - background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", - color: COLORS.accent, - fontFamily: MONO_FONT, - }} - > - {missions.length} TOTAL - </span> - </div> - <div className="flex items-center gap-1"> - <button - onClick={() => void refreshMissionList({ preserveSelection: true, refreshDashboard: true })} - className="p-1 transition-colors" - style={{ color: COLORS.textMuted }} - title="Refresh" - > - {refreshing ? ( - <SpinnerGap className="h-3.5 w-3.5 animate-spin" /> - ) : ( - <ArrowsClockwise className="h-3.5 w-3.5" /> - )} - </button> - <button - onClick={() => { - setMissionSettingsOpen(true); - setMissionSettingsNotice(null); - setMissionSettingsError(null); - if (!missionSettingsSnapshot) { - void loadMissionSettings(); - } - }} - className="p-1 transition-colors" - style={{ color: COLORS.textMuted }} - title="Mission Settings" - > - <GearSix className="h-3.5 w-3.5" /> - </button> - <button - onClick={() => openMissionCreateDialog()} - className="p-1 transition-colors" - style={{ color: COLORS.accent }} - title="New Mission" - > - <Plus className="h-3.5 w-3.5" /> - </button> - </div> - </div> - - {/* View mode toggle + Search */} - <div className="px-2.5 py-2 space-y-2"> - <div className="flex items-center gap-2"> - <div className="relative flex-1"> - <MagnifyingGlass - className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" - style={{ color: COLORS.textDim }} - /> - <input - value={searchFilter} - onChange={(e) => setSearchFilter(e.target.value)} - placeholder="Search missions..." - className="h-7 w-full pl-7 pr-2 text-xs outline-none" - style={{ - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - }} - /> - </div> - <div - className="flex gap-0.5 p-0.5" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }} - > - <button - className="px-1.5 py-1 text-xs" - style={ - missionListView === "list" - ? { background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", color: COLORS.textPrimary } - : { color: COLORS.textMuted } - } - onClick={() => setMissionListView("list")} - title="List view" - > - <List size={14} weight="regular" /> - </button> - <button - className="px-1.5 py-1 text-xs" - style={ - missionListView === "board" - ? { background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", color: COLORS.textPrimary } - : { color: COLORS.textMuted } - } - onClick={() => setMissionListView("board")} - title="Board view" - > - <Kanban size={14} weight="regular" /> - </button> - </div> - </div> - </div> - - {/* Mission list / board */} - <div className={cn("flex-1 min-h-0 px-2 pb-2", missionListView === "list" && filteredMissions.length > 0 ? "overflow-hidden" : "overflow-y-auto")}> - {filteredMissions.length === 0 ? ( - <div className="px-2 py-8 text-center text-xs" style={{ color: COLORS.textDim }}> - {missions.length === 0 ? ( - <div className="flex flex-col items-center gap-2"> - <Rocket size={28} weight="regular" style={{ color: "color-mix(in srgb, var(--color-accent) 40%, transparent)" }} /> - <p>No missions yet. Missions coordinate your AI agents to accomplish complex tasks.</p> - <button onClick={() => openMissionCreateDialog()} style={primaryButton()}> - START MISSION - </button> - </div> - ) : ( - "No matches" - )} - </div> - ) : missionListView === "board" ? ( - <MissionBoardView - missions={filteredMissions} - selectedMissionId={selectedMissionId} - selectedDisplayStatus={selectedDisplayStatus} - onSelect={setSelectedMissionId} - onContextMenu={handleMissionContextMenu} - /> - ) : ( - <MissionListView - missions={filteredMissions} - selectedMissionId={selectedMissionId} - selectedDisplayStatus={selectedDisplayStatus} - onSelect={setSelectedMissionId} - onContextMenu={handleMissionContextMenu} - /> - )} - </div> - - <MissionSidebarStatusBlock - missionTitle={selectedMission?.title ?? null} - missionPrompt={selectedMission?.prompt ?? null} - openInterventions={selectedMission?.openInterventions ?? 0} - artifactCount={selectedMission?.artifactCount ?? 0} - status={selectedDisplayStatus} - summary={selectedMissionNarrative?.detail ?? null} - headline={selectedMissionNarrative?.title ?? null} - phaseName={runView?.active.phaseName ?? null} - stepTitle={runView?.active.stepTitle ?? null} - updatedAt={selectedMissionNarrative?.at ?? runView?.lastMeaningfulProgress?.at ?? selectedMission?.updatedAt ?? null} - /> - </div> - ); -} - -const SIDEBAR_STATUS_TONES: Record<MissionRunViewDisplayStatus, { style: CSSProperties; label: string }> = { - not_started: { style: { background: "#6B728018", color: "#6B7280", border: "1px solid #6B728030" }, label: "Not started" }, - starting: { style: { background: "#A78BFA18", color: "#A78BFA", border: "1px solid #A78BFA30" }, label: "Starting" }, - running: { style: { background: "#22C55E18", color: "#22C55E", border: "1px solid #22C55E30" }, label: "Running" }, - paused: { style: { background: "#F59E0B18", color: "#F59E0B", border: "1px solid #F59E0B30" }, label: "Paused" }, - blocked: { style: { background: "#F59E0B18", color: "#F59E0B", border: "1px solid #F59E0B30" }, label: "Blocked" }, - completed: { style: { background: "#22C55E18", color: "#22C55E", border: "1px solid #22C55E30" }, label: "Completed" }, - failed: { style: { background: "#EF444418", color: "#EF4444", border: "1px solid #EF444430" }, label: "Failed" }, - canceled: { style: { background: "#6B728018", color: "#6B7280", border: "1px solid #6B728030" }, label: "Canceled" }, -}; - -function MissionSidebarStatusBlock(props: { - missionTitle: string | null; - missionPrompt: string | null; - openInterventions: number; - artifactCount: number; - status: MissionRunViewDisplayStatus | null; - headline: string | null; - summary: string | null; - phaseName: string | null; - stepTitle: string | null; - updatedAt: string | null; -}) { - const { - missionTitle, - missionPrompt, - openInterventions, - artifactCount, - status, - headline, - summary, - phaseName, - stepTitle, - updatedAt, - } = props; - - return ( - <div className="shrink-0 px-2.5 pb-2.5 pt-2" style={{ borderTop: `1px solid ${COLORS.border}` }}> - <div className="space-y-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, padding: "10px" }}> - <div className="flex items-center justify-between gap-2"> - <span - className="text-[10px] font-bold uppercase tracking-[1px]" - style={{ color: COLORS.textDim, fontFamily: MONO_FONT }} - > - Selected mission - </span> - {updatedAt ? ( - <span className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {relativeWhen(updatedAt)} - </span> - ) : null} - </div> - - {missionTitle ? ( - <> - <div className="space-y-1"> - <div className="text-[12px] font-semibold leading-[1.35]" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}> - {missionTitle} - </div> - {status ? ( - <span - className="inline-flex items-center px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.9px]" - style={SIDEBAR_STATUS_TONES[status].style} - > - {SIDEBAR_STATUS_TONES[status].label} - </span> - ) : null} - </div> - - {headline ? ( - <div className="text-[11px] font-semibold leading-[1.45]" style={{ color: COLORS.textSecondary }}> - {headline} - </div> - ) : null} - - <div className="space-y-1 text-[11px] leading-[1.45]" style={{ color: COLORS.textMuted }}> - <div>{summary || missionPrompt || "Open this mission to see its latest runtime state."}</div> - {(phaseName || stepTitle) && ( - <div className="flex flex-wrap items-center gap-1.5"> - {phaseName ? ( - <span className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.6px]" style={{ background: "color-mix(in srgb, var(--color-accent) 16%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 28%, transparent)", color: COLORS.accent, fontFamily: MONO_FONT }}> - {phaseName} - </span> - ) : null} - {stepTitle ? ( - <span className="truncate" style={{ color: COLORS.textSecondary }}> - {stepTitle} - </span> - ) : null} - </div> - )} - </div> - - <div className="flex items-center gap-2 text-[10px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - <span>{openInterventions} open</span> - <span>{artifactCount} artifacts</span> - </div> - </> - ) : ( - <div className="text-[11px] leading-[1.45]" style={{ color: COLORS.textMuted }}> - Pick a mission to keep its current run state visible while you browse the queue. - </div> - )} - </div> - </div> - ); -} - -/* ────────── Board View ────────── */ - -function MissionBoardView(props: { - missions: MissionSummary[]; - selectedMissionId: string | null; - selectedDisplayStatus: MissionRunViewDisplayStatus | null; - onSelect: (id: string) => void; - onContextMenu: (m: MissionSummary, e: ReactMouseEvent<HTMLButtonElement>) => void; -}) { - const { missions, selectedMissionId, selectedDisplayStatus, onSelect, onContextMenu } = props; - return ( - <div className="space-y-3 pt-1"> - {MISSION_BOARD_COLUMNS.map((col) => { - const colMissions = missions.filter((m) => m.status === col.key); - if (colMissions.length === 0) return null; - return ( - <div key={col.key}> - <div className="flex items-center gap-2 mb-1.5 px-1"> - <span - className="text-[10px] font-bold uppercase tracking-[1px]" - style={{ color: col.hex, fontFamily: MONO_FONT }} - > - {col.label} - </span> - <span className="text-[10px]" style={{ color: COLORS.textDim }}> - {colMissions.length} - </span> - </div> - <div className="space-y-1"> - {colMissions.map((m) => ( - <MissionBoardCard - key={m.id} - mission={m} - isSelected={m.id === selectedMissionId} - displayStatus={m.id === selectedMissionId ? selectedDisplayStatus : null} - onSelect={onSelect} - onContextMenu={onContextMenu} - /> - ))} - </div> - </div> - ); - })} - </div> - ); -} - -function MissionBoardCard(props: { - mission: MissionSummary; - isSelected: boolean; - displayStatus: MissionRunViewDisplayStatus | null; - onSelect: (id: string) => void; - onContextMenu: (m: MissionSummary, e: ReactMouseEvent<HTMLButtonElement>) => void; -}) { - const { mission: m, isSelected, displayStatus, onSelect, onContextMenu } = props; - return ( - <button - onClick={() => onSelect(m.id)} - onContextMenu={(event) => onContextMenu(m, event)} - className="w-full text-left p-2.5 transition-colors" - style={ - isSelected - ? { - background: "#A78BFA12", - borderTop: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", - borderRight: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", - borderBottom: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", - borderLeft: `3px solid ${COLORS.accent}`, - } - : { background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` } - } - > - <div className="flex items-center gap-1.5"> - <MissionStatusDot mission={m} displayStatus={displayStatus} /> - <div className="text-xs font-medium truncate flex-1" style={{ color: COLORS.textPrimary }}> - {m.title} - </div> - <MissionInterventionBadge count={m.openInterventions} missionStatus={m.status} /> - </div> - <div className="mt-1 text-[11px] truncate" style={{ color: COLORS.textMuted }}> - {m.prompt} - </div> - <div className="mt-1.5 flex items-center gap-2"> - <span className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {relativeWhen(m.createdAt)} - </span> - {m.totalSteps > 0 && ( - <span className="text-[10px] ml-auto" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {m.completedSteps}/{m.totalSteps} - </span> - )} - </div> - </button> - ); -} - -/* ────────── List View ────────── */ - -function MissionListView(props: { - missions: MissionSummary[]; - selectedMissionId: string | null; - selectedDisplayStatus: MissionRunViewDisplayStatus | null; - onSelect: (id: string) => void; - onContextMenu: (m: MissionSummary, e: ReactMouseEvent<HTMLButtonElement>) => void; -}) { - const { missions, selectedMissionId, selectedDisplayStatus, onSelect, onContextMenu } = props; - const parentRef = useRef<HTMLDivElement>(null); - const virtualizer = useVirtualizer({ - count: missions.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 64, - overscan: 5, - }); - return ( - <div ref={parentRef} className="h-full overflow-y-auto" data-testid="mission-list-virtual"> - <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> - {virtualizer.getVirtualItems().map((virtualRow) => { - const m = missions[virtualRow.index]!; - return ( - <div - key={m.id} - data-index={virtualRow.index} - ref={virtualizer.measureElement} - style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${virtualRow.start}px)` }} - > - <MissionListItem - mission={m} - isSelected={m.id === selectedMissionId} - displayStatus={m.id === selectedMissionId ? selectedDisplayStatus : null} - onSelect={onSelect} - onContextMenu={onContextMenu} - /> - </div> - ); - })} - </div> - </div> - ); -} - -function MissionListItem(props: { - mission: MissionSummary; - isSelected: boolean; - displayStatus: MissionRunViewDisplayStatus | null; - onSelect: (id: string) => void; - onContextMenu: (m: MissionSummary, e: ReactMouseEvent<HTMLButtonElement>) => void; -}) { - const { mission: m, isSelected, displayStatus, onSelect, onContextMenu } = props; - const progress = m.totalSteps > 0 ? Math.round((m.completedSteps / m.totalSteps) * 100) : 0; - const isActive = isMissionVisiblyActive(m.status, displayStatus); - const badgeStyle = getMissionStatusBadgeConfig(m.status, displayStatus); - return ( - <button - onClick={() => onSelect(m.id)} - onContextMenu={(event) => onContextMenu(m, event)} - className="w-full text-left px-2.5 py-2 transition-colors" - style={isSelected - ? { background: "#A78BFA12", borderTop: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", borderRight: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", borderBottom: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", borderLeft: `3px solid ${COLORS.accent}` } - : isActive - ? { background: "color-mix(in srgb, var(--color-info) 6%, transparent)", border: "1px solid color-mix(in srgb, var(--color-info) 16%, transparent)" } - : { border: "1px solid transparent" }} - > - <div className="flex items-start gap-2"> - <MissionListStatusDot mission={m} displayStatus={displayStatus} /> - <div className="min-w-0 flex-1"> - <div className="truncate text-xs font-medium" style={{ color: COLORS.textPrimary }}>{m.title}</div> - <div className="mt-0.5 flex items-center gap-1.5"> - <span className="px-1 py-0.5 text-[10px] font-bold uppercase tracking-[1px]" style={{ background: badgeStyle.background, color: badgeStyle.color, border: badgeStyle.border, fontFamily: MONO_FONT }}>{badgeStyle.label}</span> - <MissionInterventionBadge count={m.openInterventions} missionStatus={m.status} /> - </div> - {m.totalSteps > 0 && ( - <div className="mt-1.5 flex items-center gap-2"> - <div className="h-1 flex-1" style={{ background: COLORS.recessedBg }}> - <div className="h-1 transition-all" style={{ width: `${progress}%`, background: COLORS.accent }} /> - </div> - <span className="shrink-0 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>{m.completedSteps}/{m.totalSteps}</span> - </div> - )} - </div> - </div> - </button> - ); -} - -function MissionListStatusDot({ mission: m, displayStatus }: { mission: MissionSummary; displayStatus: MissionRunViewDisplayStatus | null }) { - const baseStatus = getMissionStatusBadgeConfig(m.status, displayStatus); - const attention = getMissionAttentionState(m, displayStatus); - const dotColor = attention?.color ?? baseStatus.color; - return ( - <span - className="mt-1 h-2 w-2 shrink-0" - style={{ background: dotColor, borderRadius: attention ? "50%" : 0, boxShadow: attention ? `0 0 6px ${dotColor}60` : "none" }} - /> - ); -} - -/* ────────── Shared small components ────────── */ - -function MissionStatusDot({ mission: m, displayStatus }: { mission: MissionSummary; displayStatus: MissionRunViewDisplayStatus | null }) { - const attention = getMissionAttentionState(m, displayStatus); - if (!attention) return null; - return ( - <span - className="shrink-0" - style={{ - width: 6, - height: 6, - borderRadius: "50%", - background: attention.color, - boxShadow: `0 0 6px ${attention.color}60`, - }} - title={attention.title} - /> - ); -} - -function getMissionAttentionState( - mission: MissionSummary, - displayStatus: MissionRunViewDisplayStatus | null, -): { color: string; title: string } | null { - if (displayStatus === "blocked" || mission.status === "intervention_required") { - return { color: "#F59E0B", title: "Needs attention" }; - } - if (mission.status === "in_progress" && mission.openInterventions > 0) { - return { - color: "#3B82F6", - title: `${mission.openInterventions} open intervention${mission.openInterventions === 1 ? "" : "s"}`, - }; - } - if (displayStatus === "failed" || mission.status === "failed") { - return { color: "#EF4444", title: "Failed" }; - } - return null; -} - -function MissionInterventionBadge({ count, missionStatus }: { count: number; missionStatus: MissionSummary["status"] }) { - if (count <= 0) return null; - if (missionStatus === "completed" || missionStatus === "canceled") return null; - return ( - <span - className="shrink-0 px-1 py-0.5 text-[10px] font-bold" - style={{ - color: COLORS.warning, - background: "color-mix(in srgb, var(--color-warning) 18%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-warning) 30%, transparent)", - fontFamily: MONO_FONT, - }} - title={`${count} pending intervention${count === 1 ? "" : "s"}`} - > - {count > 1 ? `${count}!` : "!"} - </span> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionTabContainer.test.tsx b/apps/desktop/src/renderer/components/missions/MissionTabContainer.test.tsx deleted file mode 100644 index 7d8f1f09d..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionTabContainer.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* @vitest-environment jsdom */ - -import React from "react"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MissionDashboardSnapshot, MissionSummary } from "../../../shared/types"; -import { ManageMissionDialog } from "./ManageMissionDialog"; -import { MissionsHomeDashboard } from "./MissionsHomeDashboard"; -import { MissionTabNavigation } from "./MissionTabContainer"; -import { initialMissionsState, useMissionsStore } from "./useMissionsStore"; - -vi.mock("./MissionChatV2", () => ({ - MissionChatV2: () => null, -})); - -function makeMission(id: string, status: MissionSummary["status"]): MissionSummary { - return { - id, - title: `Mission ${id}`, - prompt: `Prompt ${id}`, - status, - priority: "normal", - executionMode: "local", - laneId: null, - laneName: "Primary", - missionLaneId: null, - missionLaneName: null, - resultLaneId: null, - resultLaneName: null, - totalSteps: 0, - completedSteps: 0, - openInterventions: 0, - artifactCount: 0, - createdAt: "2026-05-20T00:00:00.000Z", - updatedAt: "2026-05-20T00:00:00.000Z", - startedAt: null, - completedAt: null, - lastError: null, - outcomeSummary: null, - targetMachineId: null, - }; -} - -function makeDashboard(): MissionDashboardSnapshot { - return { - active: [], - recent: [ - { mission: makeMission("completed", "completed"), action: "rerun", durationMs: 12_000, costEstimateUsd: null }, - { mission: makeMission("failed", "failed"), action: "retry", durationMs: 15_000, costEstimateUsd: null }, - ], - weekly: { - missions: 2, - successRate: 0.5, - avgDurationMs: 13_500, - totalCostUsd: 0, - }, - }; -} - -describe("MissionTabNavigation", () => { - beforeEach(() => { - useMissionsStore.setState({ ...initialMissionsState, activeTab: "chat" }); - }); - - it("exposes selected mission view state with tab semantics", () => { - render(<MissionTabNavigation />); - - expect(screen.getByRole("tablist", { name: "Mission views" })).toBeTruthy(); - expect(screen.getByRole("tab", { name: "Conversations" }).getAttribute("aria-selected")).toBe("true"); - expect(screen.getByRole("tab", { name: "Overview" }).getAttribute("aria-selected")).toBe("false"); - - fireEvent.click(screen.getByRole("tab", { name: "Plan" })); - - expect(useMissionsStore.getState().activeTab).toBe("plan"); - expect(screen.getByRole("tab", { name: "Plan" }).getAttribute("aria-selected")).toBe("true"); - expect(screen.getByRole("tab", { name: "Conversations" }).getAttribute("aria-selected")).toBe("false"); - }); -}); - -describe("mission dashboard and management controls", () => { - beforeEach(() => { - cleanup(); - vi.clearAllMocks(); - useMissionsStore.setState({ ...initialMissionsState }); - window.confirm = vi.fn(() => true); - window.ade = { - missions: { - archive: vi.fn(async () => undefined), - list: vi.fn(async () => []), - getDashboard: vi.fn(async () => ({ - ...makeDashboard(), - recent: [], - })), - }, - orchestrator: { - listRuns: vi.fn(async () => []), - cancelRun: vi.fn(async () => undefined), - cleanupTeamResources: vi.fn(async () => ({ - laneIds: ["lane-1"], - lanesArchived: ["lane-1"], - laneErrors: [], - })), - }, - } as any; - }); - - it("labels recent mission actions as view because they only select the mission", () => { - const onViewMission = vi.fn(); - - render( - <MissionsHomeDashboard - snapshot={makeDashboard()} - onNewMission={vi.fn()} - onViewMission={onViewMission} - />, - ); - - expect(screen.queryByRole("button", { name: "RERUN" })).toBeNull(); - expect(screen.queryByRole("button", { name: "RETRY" })).toBeNull(); - const viewButtons = screen.getAllByRole("button", { name: /^View mission / }); - expect(viewButtons).toHaveLength(2); - - fireEvent.click(viewButtons[1]!); - - expect(onViewMission).toHaveBeenCalledWith("failed"); - }); - - it("asks before archiving and names lane cleanup when selected", () => { - useMissionsStore.setState({ - manageMission: makeMission("mission-1", "completed"), - manageMissionOpen: true, - }); - window.confirm = vi.fn(() => false); - - render(<ManageMissionDialog />); - - fireEvent.click(screen.getByRole("checkbox", { name: /also archive lanes created by this mission/i })); - fireEvent.click(screen.getByRole("button", { name: /archive mission/i })); - - expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("also archive lanes created by this mission")); - expect(window.ade.orchestrator.cleanupTeamResources).not.toHaveBeenCalled(); - expect(window.ade.missions.archive).not.toHaveBeenCalled(); - }); - - it("does not cancel a non-terminal mission when confirmation is rejected", () => { - useMissionsStore.setState({ - manageMission: makeMission("mission-2", "in_progress"), - manageMissionOpen: true, - }); - window.confirm = vi.fn(() => false); - - render(<ManageMissionDialog />); - - fireEvent.click(screen.getByRole("button", { name: /cancel mission/i })); - - expect(window.confirm).toHaveBeenCalledWith("This will cancel the entire mission. Are you sure?"); - expect(window.ade.orchestrator.listRuns).not.toHaveBeenCalled(); - expect(window.ade.orchestrator.cancelRun).not.toHaveBeenCalled(); - }); - - it("cancels the active run after confirmation", async () => { - useMissionsStore.setState({ - manageMission: makeMission("mission-3", "in_progress"), - manageMissionOpen: true, - }); - (window.ade.orchestrator as any).listRuns = vi.fn(async () => [ - { id: "run-1", status: "active" }, - ]); - - render(<ManageMissionDialog />); - - fireEvent.click(screen.getByRole("button", { name: /cancel mission/i })); - - await waitFor(() => { - expect(window.ade.orchestrator.cancelRun).toHaveBeenCalledWith({ - runId: "run-1", - reason: "Canceled from Manage Mission dialog.", - }); - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionTabContainer.tsx b/apps/desktop/src/renderer/components/missions/MissionTabContainer.tsx deleted file mode 100644 index 52f322ed1..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionTabContainer.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { useMemo, useState } from "react"; -import { - SquaresFour, - ChatCircle, - Pulse, -} from "@phosphor-icons/react"; -import type { - MissionRunView, - OrchestratorAttempt, - PhaseCard, -} from "../../../shared/types"; -import { cn } from "../ui/cn"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; -import { type WorkspaceTab, isRecord } from "./missionHelpers"; -import { useMissionsStore, useMissionsStoreApi, type MissionsStore } from "./useMissionsStore"; -import { useShallow } from "zustand/react/shallow"; - -/* ── Imported tab content components ── */ -import { OrchestratorActivityFeed } from "./OrchestratorActivityFeed"; -import { CompletionBanner } from "./CompletionBanner"; -import { MissionChatV2 } from "./MissionChatV2"; -import { PlanTab } from "./PlanTab"; -import { StepDetailPanel } from "./StepDetailPanel"; -import { ActivityNarrativeHeader } from "./ActivityNarrativeHeader"; -import { MissionRunPanel } from "./MissionRunPanel"; -import { MissionLogsTab } from "./MissionLogsTab"; -import { MissionArtifactsTab } from "./MissionArtifactsTab"; -import { MissionActivePhasePanel } from "./MissionActivePhasePanel"; -import { MissionControlOfficePanel } from "./MissionControlOfficePanel"; -import { PromptInspectorCard } from "./PromptInspectorCard"; -import { buildMissionArtifactGroups, deriveActivePhaseViewModel } from "./missionControlViewModel"; -import { routeMissionIntervention } from "./missionInterventionRouting"; - -/* ════════════════════ TAB NAVIGATION ════════════════════ */ - -/** - * Flat tab navigation — all views 1-click reachable. (VAL-UX-004) - * No sub-toggles: planSubview and activityPanelMode eliminated from tab nav. - */ -export function MissionTabNavigation() { - const activeTab = useMissionsStore((s) => s.activeTab); - const setActiveTab = useMissionsStore((s) => s.setActiveTab); - - const tabs: Array<{ key: WorkspaceTab; label: string; icon: typeof SquaresFour }> = [ - { key: "overview", label: "Overview", icon: SquaresFour }, - { key: "chat", label: "Conversations", icon: ChatCircle }, - { key: "plan", label: "Plan", icon: SquaresFour }, - { key: "history", label: "Timeline", icon: Pulse }, - { key: "artifacts", label: "Artifacts", icon: Pulse }, - ]; - - return ( - <div - role="tablist" - aria-label="Mission views" - className="flex items-center gap-0 px-3" - style={{ borderBottom: `1px solid ${COLORS.border}` }} - > - {tabs.map((tab) => { - const isActive = activeTab === tab.key; - return ( - <button - key={tab.key} - id={`mission-tab-${tab.key}`} - type="button" - role="tab" - aria-selected={isActive} - aria-controls={`mission-panel-${tab.key}`} - tabIndex={isActive ? 0 : -1} - onClick={() => setActiveTab(tab.key)} - className="flex items-center gap-1.5 px-2.5 py-2 text-[11px] transition-colors" - style={{ - color: isActive ? COLORS.textPrimary : COLORS.textMuted, - fontFamily: SANS_FONT, - fontWeight: isActive ? 600 : 400, - borderBottom: isActive ? `2px solid ${COLORS.accent}` : "2px solid transparent", - background: "transparent", - }} - > - <tab.icon className="h-3.5 w-3.5" style={{ color: isActive ? COLORS.accent : COLORS.textDim }} /> - <span>{tab.label}</span> - </button> - ); - })} - </div> - ); -} - -/* ══════════════════ Grouped selectors (VAL-ARCH-008) ══════════════════ */ - -/** Core render data for tab content — grouped to avoid N separate subscriptions. */ -const selectTabContentData = (s: MissionsStore) => ({ - selectedMissionId: s.selectedMissionId, - selectedMission: s.selectedMission, - runGraph: s.runGraph, - activeTab: s.activeTab, - selectedStepId: s.selectedStepId, - orchestratorArtifacts: s.orchestratorArtifacts, - workerCheckpoints: s.workerCheckpoints, - modelCapabilities: s.modelCapabilities, - chatJumpTarget: s.chatJumpTarget, - logsFocusInterventionId: s.logsFocusInterventionId, -}); - -/** Prompt inspector state — rarely changes, grouped separately. */ -const selectPromptInspectorData = (s: MissionsStore) => ({ - coordinatorPromptInspector: s.coordinatorPromptInspector, - coordinatorPromptLoading: s.coordinatorPromptLoading, - coordinatorPromptError: s.coordinatorPromptError, - workerPromptInspector: s.workerPromptInspector, - workerPromptLoading: s.workerPromptLoading, - workerPromptError: s.workerPromptError, -}); - -/* ════════════════════ TAB CONTENT ════════════════════ */ - -export function MissionTabContent({ runView }: { runView: MissionRunView | null }) { - const missionsStore = useMissionsStoreApi(); - /* ── Grouped selectors (VAL-ARCH-008) ── */ - const { - selectedMissionId, - selectedMission, - runGraph, - activeTab, - selectedStepId, - orchestratorArtifacts, - workerCheckpoints, - modelCapabilities, - chatJumpTarget, - logsFocusInterventionId, - } = useMissionsStore(useShallow(selectTabContentData)); - - const { - coordinatorPromptInspector, - coordinatorPromptLoading, - coordinatorPromptError, - workerPromptInspector, - workerPromptLoading, - workerPromptError, - } = useMissionsStore(useShallow(selectPromptInspectorData)); - - const runSteps = useMemo(() => runGraph?.steps ?? [], [runGraph?.steps]); - const runClaims = useMemo(() => runGraph?.claims ?? [], [runGraph?.claims]); - const runTimeline = useMemo(() => runGraph?.timeline ?? [], [runGraph?.timeline]); - - const attemptsByStep = useMemo(() => { - const map = new Map<string, OrchestratorAttempt[]>(); - if (!runGraph) return map; - for (const attempt of runGraph.attempts) { - const bucket = map.get(attempt.stepId) ?? []; - bucket.push(attempt); - map.set(attempt.stepId, bucket); - } - return map; - }, [runGraph]); - - const selectedStep = useMemo(() => { - if (!selectedStepId || !runGraph?.steps?.length) return null; - return runGraph.steps.find((step) => step.id === selectedStepId) ?? null; - }, [runGraph, selectedStepId]); - - const selectedStepAttempts = useMemo(() => { - if (!selectedStep) return []; - return attemptsByStep.get(selectedStep.id) ?? []; - }, [attemptsByStep, selectedStep]); - - const steeringLog = useMemo<Array<{ directive: string; appliedAt: string }>>(() => [], []); - - const activePhaseView = useMemo( - () => deriveActivePhaseViewModel({ mission: selectedMission, runGraph, modelCapabilities }), - [modelCapabilities, runGraph, selectedMission], - ); - - const missionPhaseBadge = useMemo(() => { - const runMeta = isRecord(runGraph?.run?.metadata) ? runGraph.run.metadata : null; - const runPhaseOverride = Array.isArray(runMeta?.phaseOverride) - ? (runMeta.phaseOverride as PhaseCard[]) - : null; - const missionPhaseOverride = Array.isArray(selectedMission?.phaseConfiguration?.selectedPhases) - ? selectedMission.phaseConfiguration.selectedPhases - : null; - return { - phases: runPhaseOverride && runPhaseOverride.length > 0 ? runPhaseOverride : missionPhaseOverride, - }; - }, [runGraph?.run?.metadata, selectedMission?.phaseConfiguration]); - - const groupedArtifacts = useMemo( - () => - buildMissionArtifactGroups({ - mission: selectedMission, - runGraph, - orchestratorArtifacts, - checkpoints: workerCheckpoints, - }), - [orchestratorArtifacts, runGraph, selectedMission, workerCheckpoints], - ); - - const chatFocused = activeTab === "chat"; - const compactPhaseChrome = chatFocused; - const showCompletionBanner = activeTab !== "chat"; - const [showRawTimelineLogs, setShowRawTimelineLogs] = useState(false); - - /* ── Prompt inspectors (imperative store access to avoid extra subscriptions) ── */ - const loadCoordinatorPromptInspector = async () => { - const s = missionsStore.getState(); - if (!s.runGraph) return; - s.setCoordinatorPromptLoading(true); - s.setCoordinatorPromptError(null); - try { - const inspector = await window.ade.orchestrator.getPromptInspector({ - runId: s.runGraph.run.id, - target: "coordinator", - }); - s.setCoordinatorPromptInspector(inspector); - } catch (err) { - s.setCoordinatorPromptError(err instanceof Error ? err.message : String(err)); - } finally { - s.setCoordinatorPromptLoading(false); - } - }; - - const loadWorkerPromptInspector = async (stepId: string) => { - const s = missionsStore.getState(); - if (!s.runGraph) return; - s.setWorkerPromptLoading(true); - s.setWorkerPromptError(null); - try { - const inspector = await window.ade.orchestrator.getPromptInspector({ - runId: s.runGraph.run.id, - target: "worker", - stepId, - }); - s.setWorkerPromptInspector(inspector); - } catch (err) { - s.setWorkerPromptError(err instanceof Error ? err.message : String(err)); - } finally { - s.setWorkerPromptLoading(false); - } - }; - - /* ── Intervention handling (imperative store access) ── */ - const handleOpenIntervention = (interventionId: string) => { - const s = missionsStore.getState(); - routeMissionIntervention(s, interventionId); - }; - - return ( - <> - {runGraph && ( - <MissionActivePhasePanel activePhase={activePhaseView} allPhases={missionPhaseBadge.phases} promptInspector={coordinatorPromptInspector} promptLoading={coordinatorPromptLoading} promptError={coordinatorPromptError} onInspectPrompt={() => void loadCoordinatorPromptInspector()} coordinatorAvailability={runView?.coordinator.available != null ? { available: runView.coordinator.available, mode: runView.coordinator.mode ?? "offline", summary: runView.coordinator.summary ?? null } : null} compact={compactPhaseChrome} /> - )} - - {/* Completion Banner */} - {runGraph && showCompletionBanner && (<div className="px-4 pt-3 space-y-2"><CompletionBanner status={runGraph.run.status} evaluation={runGraph.completionEvaluation} runId={runGraph.run.id} /></div>)} - {/* Tab Content */} - <div - id={`mission-panel-${activeTab}`} - role="tabpanel" - aria-labelledby={`mission-tab-${activeTab}`} - className={cn( - "flex-1 min-h-0", - activeTab === "chat" ? "flex flex-col overflow-hidden" : "overflow-y-auto overflow-x-hidden p-4", - )} - > - {activeTab === "overview" && selectedMission && ( - <div className="space-y-3"> - <MissionControlOfficePanel - mission={selectedMission} - runView={runView} - runGraph={runGraph} - phases={missionPhaseBadge.phases} - onOpenIntervention={handleOpenIntervention} - /> - <MissionRunPanel - runView={runView} - interventions={selectedMission.interventions} - onOpenIntervention={handleOpenIntervention} - showInterventions={false} - hideInterventionHaltReason - /> - <div className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-semibold" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Mission brief - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textSecondary }}> - The current run state, blockers, active workers, and latest progress appear above. The brief stays here for reference without taking over the page. - </div> - <div className="mt-2 max-h-[180px] overflow-auto text-[12px]" style={{ color: COLORS.textPrimary, whiteSpace: "pre-wrap", wordBreak: "break-word" }}> - {selectedMission.prompt} - </div> - </div> - </div> - )} - - {activeTab === "plan" && ( - <div className="space-y-3"> - <div className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Plan review - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textSecondary }}> - Review the planner summary, milestone or feature groupings, dependency graph, and phase-by-phase work breakdown below. Selected step details stay visible here instead of living in a separate split pane. - </div> - </div> - <StepDetailPanel step={selectedStep} attempts={selectedStepAttempts} allSteps={runSteps} claims={runClaims} onOpenWorkerThread={(target) => { missionsStore.getState().setChatJumpTarget(target); missionsStore.getState().setActiveTab("chat"); }} onInspectPrompt={(stepId) => void loadWorkerPromptInspector(stepId)} /> - {selectedStep ? <PromptInspectorCard inspector={workerPromptInspector} loading={workerPromptLoading} error={workerPromptError} title="Selected step effective prompt" /> : null} - <div className="min-h-0 min-w-0 overflow-auto"> - <PlanTab mission={selectedMission} runGraph={runGraph} attemptsByStep={attemptsByStep} selectedStepId={selectedStepId} onStepSelect={(id) => missionsStore.getState().setSelectedStepId(id)} /> - </div> - </div> - )} - - {activeTab === "history" && ( - <div className="space-y-3"> - <div className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Timeline diagnostics - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textSecondary }}> - Raw orchestration events, scheduler activity, and runtime internals live here. Use Conversations for readable mission updates. - </div> - </div> - <ActivityNarrativeHeader runGraph={runGraph} steeringLog={steeringLog} /> - <OrchestratorActivityFeed runId={runGraph?.run.id ?? ""} initialTimeline={runTimeline} /> - {Array.isArray(runGraph?.run?.metadata?.runNarrative) && (runGraph.run.metadata.runNarrative as Array<{ stepKey: string; summary: string; at: string }>).length > 0 && ( - <div className="space-y-1.5 mt-4"> - <div className="text-[10px] font-bold tracking-wider uppercase" style={{ color: COLORS.textMuted }}>RUN NARRATIVE</div> - <div className="space-y-1">{(runGraph.run.metadata.runNarrative as Array<{ stepKey: string; summary: string; at: string }>).map((entry, i: number) => ( - <div key={i} className="text-[11px] flex gap-2 items-start" style={{ fontFamily: MONO_FONT }}><span className="shrink-0" style={{ color: COLORS.accent }}>{entry.stepKey}</span><span style={{ color: COLORS.textSecondary }}>{entry.summary}</span></div> - ))}</div> - </div> - )} - {selectedMission && ( - <div className="space-y-2"> - <button - type="button" - onClick={() => setShowRawTimelineLogs((value) => !value)} - className="px-2 py-1 text-[10px] font-bold uppercase tracking-[1px]" - style={{ - background: showRawTimelineLogs ? "color-mix(in srgb, var(--color-accent) 18%, transparent)" : COLORS.recessedBg, - border: showRawTimelineLogs ? "1px solid color-mix(in srgb, var(--color-accent) 35%, transparent)" : `1px solid ${COLORS.border}`, - color: showRawTimelineLogs ? COLORS.accent : COLORS.textMuted, - fontFamily: MONO_FONT, - }} - > - {showRawTimelineLogs ? "Hide raw logs" : "Show raw logs"} - </button> - {showRawTimelineLogs ? ( - <MissionLogsTab - missionId={selectedMission.id} - runId={runGraph?.run.id ?? null} - focusInterventionId={logsFocusInterventionId} - onFocusHandled={() => missionsStore.getState().setLogsFocusInterventionId(null)} - /> - ) : null} - </div> - )} - </div> - )} - - {activeTab === "chat" && selectedMissionId && ( - <div className="flex flex-1 min-h-0 min-w-0 w-full overflow-hidden"> - <MissionChatV2 - missionId={selectedMissionId} - missionStatus={selectedMission?.status ?? null} - runId={runGraph?.run.id ?? null} - runStatus={runGraph?.run.status ?? null} - runView={runView} - interventions={selectedMission?.interventions ?? []} - jumpTarget={chatJumpTarget} - onJumpHandled={() => missionsStore.getState().setChatJumpTarget(null)} - onOpenIntervention={handleOpenIntervention} - /> - </div> - )} - - {activeTab === "artifacts" && ( - <MissionArtifactsTab - groupedArtifacts={groupedArtifacts} - closeoutRequirements={runView?.closeoutRequirements ?? []} - missionId={selectedMissionId} - runId={runGraph?.run.id ?? null} - laneId={selectedMission?.laneId ?? null} - computerUseSnapshot={runView?.computerUse ?? null} - /> - )} - </div> - </> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts b/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts deleted file mode 100644 index a1f87b737..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.test.ts +++ /dev/null @@ -1,688 +0,0 @@ -/* @vitest-environment jsdom */ - -import React from "react"; -import { describe, expect, it, vi, beforeEach, afterEach, type Mock } from "vitest"; -import { render, waitFor } from "@testing-library/react"; -import type { AgentChatEventEnvelope } from "../../../shared/types"; -import { AgentChatMessageList } from "../chat/AgentChatMessageList"; -import { MissionThreadMessageList, mergeMissionThreadEvents, buildMissionThreadEventMergeKey } from "./MissionThreadMessageList"; -import { useMissionPolling } from "./useMissionPolling"; - -const agentListLifecycle = vi.hoisted(() => ({ mounts: 0, unmounts: 0 })); - -vi.mock("../chat/AgentChatMessageList", async () => { - const React = await import("react"); - return { - AgentChatMessageList: vi.fn(() => { - React.useEffect(() => { - agentListLifecycle.mounts += 1; - return () => { - agentListLifecycle.unmounts += 1; - }; - }, []); - return null; - }), - }; -}); - -vi.mock("./useMissionPolling", () => ({ - useMissionPolling: vi.fn(() => undefined), -})); - -describe("MissionThreadMessageList transcript merge", () => { - it("merges persisted and live text events by turn instead of timestamp", () => { - const fallbackEvents: AgentChatEventEnvelope[] = [ - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:00.000Z", - provenance: { - messageId: "msg-1", - threadId: "thread-1", - }, - event: { - type: "text", - turnId: "turn-1", - itemId: "text-2", - text: "Plan is ready", - }, - }, - ]; - - const sessionEvents: AgentChatEventEnvelope[] = [ - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:01.000Z", - event: { - type: "text", - turnId: "turn-1", - itemId: "text-1", - text: "Plan", - }, - }, - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:02.000Z", - event: { - type: "text", - turnId: "turn-1", - itemId: "text-3", - text: "with tests", - }, - }, - ]; - - const merged = mergeMissionThreadEvents(fallbackEvents, sessionEvents); - - expect(merged).toHaveLength(1); - expect(merged[0]?.event).toMatchObject({ - type: "text", - turnId: "turn-1", - itemId: "text-3", - text: "Plan is ready with tests", - }); - expect(merged[0]?.provenance).toMatchObject({ - messageId: "msg-1", - threadId: "thread-1", - }); - expect(merged[0]?.timestamp).toBe("2026-03-10T12:00:02.000Z"); - }); - - it("keeps distinct reasoning blocks separated by item id", () => { - const merged = mergeMissionThreadEvents([], [ - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:00.000Z", - event: { - type: "reasoning", - turnId: "turn-1", - itemId: "reasoning-1", - text: "First thought.", - }, - }, - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:01.000Z", - event: { - type: "reasoning", - turnId: "turn-1", - itemId: "reasoning-2", - text: "Second thought.", - }, - }, - ]); - - const reasoning = merged.filter((envelope) => envelope.event.type === "reasoning"); - expect(reasoning).toHaveLength(2); - expect(reasoning.map((envelope) => envelope.event.type === "reasoning" ? envelope.event.text : "")).toEqual([ - "First thought.", - "Second thought.", - ]); - }); - - it("dedupes identical tool events even when transcript timestamps differ", () => { - const fallbackEvent: AgentChatEventEnvelope = { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:00.000Z", - provenance: { - messageId: "msg-tool", - }, - event: { - type: "tool_result", - tool: "read_file", - itemId: "tool-1", - turnId: "turn-9", - result: { ok: true }, - status: "completed", - }, - }; - - const sessionEvent: AgentChatEventEnvelope = { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:05.000Z", - event: { - type: "tool_result", - tool: "read_file", - itemId: "tool-1", - turnId: "turn-9", - result: { ok: true }, - status: "completed", - }, - }; - - expect(buildMissionThreadEventMergeKey(fallbackEvent)).toBe(buildMissionThreadEventMergeKey(sessionEvent)); - - const merged = mergeMissionThreadEvents([fallbackEvent], [sessionEvent]); - expect(merged).toHaveLength(1); - expect(merged[0]?.provenance).toMatchObject({ messageId: "msg-tool" }); - expect(merged[0]?.timestamp).toBe("2026-03-10T12:00:05.000Z"); - }); - - it("drops session-only low-signal fragments that never persisted into mission chat", () => { - const merged = mergeMissionThreadEvents([], [ - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:00.000Z", - event: { - type: "text", - turnId: "turn-1", - itemId: "text-1", - text: "No", - }, - }, - ]); - - expect(merged).toHaveLength(0); - }); - - it("drops synthetic coordinator prompts from mission threads", () => { - const merged = mergeMissionThreadEvents([ - { - sessionId: "coordinator-session", - timestamp: "2026-03-10T12:00:00.000Z", - provenance: { - messageId: "msg-internal", - role: "orchestrator", - targetKind: "coordinator", - }, - event: { - type: "user_message", - text: "## ADE Mission-Control Tool Transport\nInternal runtime prompt.", - displayText: "Continue mission coordination.", - turnId: "turn-1", - }, - }, - { - sessionId: "worker-session", - timestamp: "2026-03-10T12:00:01.000Z", - event: { - type: "text", - text: "Visible worker update.", - turnId: "turn-2", - itemId: "text-1", - }, - }, - ], []); - - expect(merged).toHaveLength(1); - expect(merged[0]?.event).toMatchObject({ - type: "text", - text: "Visible worker update.", - }); - }); - - it("drops coordinator mission-control replay plumbing", () => { - const merged = mergeMissionThreadEvents([ - { - sessionId: "coordinator:run-1", - timestamp: "2026-03-10T12:00:00.000Z", - provenance: { - role: "orchestrator", - targetKind: "coordinator", - }, - event: { - type: "status", - turnStatus: "interrupted", - message: "The coordinator wrote mission-control calls as text, so ADE is replaying those calls through the real mission-control tools.", - }, - }, - { - sessionId: "coordinator:run-1", - timestamp: "2026-03-10T12:00:01.000Z", - provenance: { - role: "orchestrator", - targetKind: "coordinator", - }, - event: { - type: "tool_call", - tool: "spawn_worker", - args: { name: "implementation" }, - itemId: "recovered:turn-1:0", - turnId: "turn-1", - }, - }, - { - sessionId: "coordinator:run-1", - timestamp: "2026-03-10T12:00:02.000Z", - provenance: { - role: "orchestrator", - targetKind: "coordinator", - }, - event: { - type: "error", - message: "Codex app-server exited (code=0, signal=null).", - }, - }, - { - sessionId: "coordinator-session", - timestamp: "2026-03-10T12:00:02.500Z", - provenance: { - role: "orchestrator", - targetKind: "coordinator", - }, - event: { - type: "text", - text: "// spawn_worker\n{ malformed transport text that should stay internal }", - itemId: "raw-transport", - }, - }, - { - sessionId: "coordinator:run-1", - timestamp: "2026-03-10T12:00:03.000Z", - event: { - type: "text", - text: "Visible coordinator note.", - itemId: "visible", - }, - }, - ], []); - - expect(merged).toHaveLength(1); - expect(merged[0]?.event).toMatchObject({ - type: "text", - text: "Visible coordinator note.", - }); - }); - - it("keeps mission-thread text fragments merged even when structured events interleave", () => { - const merged = mergeMissionThreadEvents([], [ - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:00.000Z", - event: { - type: "text", - turnId: "turn-7", - itemId: "text-1", - text: "Grouped command cards", - }, - }, - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:01.000Z", - event: { - type: "tool_result", - tool: "read_file", - itemId: "tool-1", - turnId: "turn-7", - status: "completed", - result: { ok: true }, - }, - }, - { - sessionId: "session-1", - timestamp: "2026-03-10T12:00:02.000Z", - event: { - type: "text", - turnId: "turn-7", - itemId: "text-1", - text: " with quieter scrolling", - }, - }, - ]); - - expect( - merged.some((envelope) => - envelope.event.type === "text" && envelope.event.text === "Grouped command cards with quieter scrolling", - ), - ).toBe(true); - }); -}); - -describe("MissionThreadMessageList usage", () => { - const agentListMock = AgentChatMessageList as unknown as Mock; - const originalAde = globalThis.window.ade; - - beforeEach(() => { - agentListMock.mockClear(); - agentListLifecycle.mounts = 0; - agentListLifecycle.unmounts = 0; - (useMissionPolling as unknown as Mock).mockClear(); - Object.defineProperty(globalThis.window, "ade", { - configurable: true, - writable: true, - value: { - sessions: { - readTranscriptTail: vi.fn(() => Promise.resolve("")), - }, - agentChat: { - getEventHistory: vi.fn(({ sessionId }: { sessionId: string }) => Promise.resolve({ - sessionId, - sessionFound: true, - events: [], - })), - }, - }, - }); - }); - - afterEach(() => { - Object.defineProperty(globalThis.window, "ade", { - configurable: true, - writable: true, - value: originalAde, - }); - }); - - it("passes mission-feed mode when no session is selected", () => { - render(React.createElement(MissionThreadMessageList, { messages: [], className: "main-timeline" })); - expect(agentListMock).toHaveBeenCalledWith( - expect.objectContaining({ surfaceMode: "mission-feed", className: "main-timeline" }), - expect.anything(), - ); - }); - - it("passes mission-thread mode when a session is provided", () => { - render(React.createElement(MissionThreadMessageList, { messages: [], sessionId: "session-abc" })); - expect(agentListMock).toHaveBeenCalledWith( - expect.objectContaining({ surfaceMode: "mission-thread" }), - expect.anything(), - ); - }); - - it("does not fetch closed mission history when transcript polling is disabled", async () => { - const readTranscriptTail = vi.fn(() => Promise.resolve("")); - const historyEvent: AgentChatEventEnvelope = { - sessionId: "session-closed", - timestamp: "2026-03-10T12:00:00.000Z", - event: { - type: "text", - text: "Closed worker transcript from agent-chat history.", - turnId: "turn-1", - itemId: "assistant-1", - }, - }; - const getEventHistory = vi.fn(({ sessionId }: { sessionId: string }) => Promise.resolve({ - sessionId, - sessionFound: true, - events: [historyEvent], - })); - Object.defineProperty(globalThis.window, "ade", { - configurable: true, - writable: true, - value: { - sessions: { - readTranscriptTail, - }, - agentChat: { - getEventHistory, - }, - }, - }); - - render(React.createElement(MissionThreadMessageList, { - messages: [], - sessionId: "session-closed", - transcriptPollingEnabled: false, - })); - - await waitFor(() => { - expect(useMissionPolling).toHaveBeenCalledWith(expect.any(Function), 4_000, false); - }); - expect(getEventHistory).not.toHaveBeenCalled(); - expect(readTranscriptTail).not.toHaveBeenCalled(); - }); - - it("hides recovered mission-control transport blocks before rendering", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-tool-transport", - missionId: "mission-1", - role: "orchestrator", - content: [ - "```json", - "// spawn_worker", - "{\"name\":\"implementation\",\"prompt\":\"Write the test\"}", - "```", - ].join("\n"), - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "mission:mission-1", - sourceSessionId: "coordinator-session", - target: { kind: "coordinator", runId: "run-1" }, - metadata: { - structuredStream: { - kind: "text", - sessionId: "coordinator-session", - turnId: "turn-1", - itemId: "text-1", - }, - }, - }, - { - id: "msg-visible", - missionId: "mission-1", - role: "orchestrator", - content: "Visible mission update.", - timestamp: "2026-03-10T12:00:01.000Z", - threadId: "mission:mission-1", - metadata: { - structuredStream: { - kind: "text", - sessionId: "coordinator-session", - turnId: "turn-2", - itemId: "text-2", - }, - }, - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "Visible mission update.", - }); - }); - - it("cleans worker result file JSON from rendered mission thread text", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-worker-result", - missionId: "mission-1", - role: "orchestrator", - content: [ - "[worker] Finished - Created the helper. *Changed Files**", - "```json", - "{\"filesChanged\":[\"src/helper.ts\",\"src/helper.test.ts\"]}", - "```", - ].join("\n"), - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "mission:mission-1", - metadata: { - structuredStream: { - kind: "text", - sessionId: "coordinator-session", - turnId: "turn-1", - itemId: "text-1", - }, - }, - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "[worker] Finished - Created the helper.\n\nChanged files:\n- src/helper.ts\n- src/helper.test.ts", - }); - }); - - it("removes dangling worker result file fences from legacy transcript snippets", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-worker-result-truncated", - missionId: "mission-1", - role: "orchestrator", - content: "[worker] Finished - Created the helper. *Changed Files** ```json", - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "mission:mission-1", - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "[worker] Finished - Created the helper.", - }); - }); - - it("removes malformed worker report payloads that were truncated mid-json", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-worker-result-truncated-json", - missionId: "mission-1", - role: "worker", - content: "I finished this task and reported back: Created the helper. Targeted test passed. *Changed Files** ```json { \"filesChanged\": [ \"src/helper.ts\"", - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "worker:mission-1:attempt-1", - sourceSessionId: "session-worker", - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "I finished this task and reported back: Created the helper. Targeted test passed.", - }); - }); - - it("hides completed done rows when they only repeat the model label", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-model-only-done", - missionId: "mission-1", - role: "worker", - content: "Turn completed.", - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "worker:mission-1:attempt-1", - metadata: { - structuredStream: { - kind: "done", - sessionId: "session-worker", - turnId: "turn-1", - status: "completed", - model: "gpt-5.5", - modelId: "openai/gpt-5.5", - }, - }, - }, - { - id: "msg-visible", - missionId: "mission-1", - role: "worker", - content: "Finished the step.", - timestamp: "2026-03-10T12:00:01.000Z", - threadId: "worker:mission-1:attempt-1", - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "Finished the step.", - }); - }); - - it("hides dangling persisted text fragments from closed worker histories", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-dangling-fragment", - missionId: "mission-1", - role: "worker", - content: "looking at the", - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "worker:mission-1:attempt-1", - metadata: { - structuredStream: { - kind: "text", - sessionId: "session-worker", - turnId: "turn-1", - itemId: "text-1", - }, - }, - }, - { - id: "msg-visible", - missionId: "mission-1", - role: "worker", - content: "Plan is ready", - timestamp: "2026-03-10T12:00:01.000Z", - threadId: "worker:mission-1:attempt-1", - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "Plan is ready", - }); - }); - - it("hides internal validation control chatter from mission transcripts", () => { - render(React.createElement(MissionThreadMessageList, { - messages: [ - { - id: "msg-validation-missing", - missionId: "mission-1", - role: "orchestrator", - content: "[test_step] Validation System: Required validation is missing for \"Run tests\". A validator pass is required before this step can be treated as complete.", - timestamp: "2026-03-10T12:00:00.000Z", - threadId: "mission:mission-1", - }, - { - id: "msg-plan-adjust", - missionId: "mission-1", - role: "orchestrator", - content: "[test_step] Progress: 4/5 steps (80%) | next: \"Validate: Run tests\" — triggering plan adjustment", - timestamp: "2026-03-10T12:00:01.000Z", - threadId: "mission:mission-1", - }, - { - id: "msg-visible", - missionId: "mission-1", - role: "orchestrator", - content: "[validate_test_step] Started worker \"Validate: Run tests\". It is now working on this step.", - timestamp: "2026-03-10T12:00:02.000Z", - threadId: "mission:mission-1", - }, - ] as any, - })); - - const events = agentListMock.mock.calls.at(-1)?.[0].events as AgentChatEventEnvelope[]; - expect(events).toHaveLength(1); - expect(events[0]?.event).toMatchObject({ - type: "text", - text: "[validate_test_step] Started worker \"Validate: Run tests\". It is now working on this step.", - }); - }); - - it("remounts the transcript list when the selected session changes", async () => { - const { rerender } = render(React.createElement(MissionThreadMessageList, { messages: [], sessionId: "session-abc" })); - - await waitFor(() => { - expect(agentListLifecycle.mounts).toBe(1); - expect(agentListLifecycle.unmounts).toBe(0); - }); - - rerender(React.createElement(MissionThreadMessageList, { messages: [], sessionId: "session-def" })); - - await waitFor(() => { - expect(agentListLifecycle.mounts).toBe(2); - expect(agentListLifecycle.unmounts).toBe(1); - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx b/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx deleted file mode 100644 index 664702704..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionThreadMessageList.tsx +++ /dev/null @@ -1,588 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AgentChatApprovalDecision, AgentChatEvent, AgentChatEventEnvelope, OrchestratorChatMessage } from "../../../shared/types"; -import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import { AgentChatMessageList } from "../chat/AgentChatMessageList"; -import { ChatSubagentsPanel } from "../chat/ChatSubagentsPanel"; -import { deriveChatSubagentSnapshots } from "../chat/chatExecutionSummary"; -import { eventHasPayload } from "../chat/chatTranscriptRows"; -import { isMissionControlToolName, rewriteMissionControlTextToolEvents } from "../chat/missionControlTextTools"; -import { derivePendingInputRequests, type DerivedPendingInput } from "../chat/pendingInput"; -import { looksLikeLowSignalNoise } from "./missionHelpers"; -import { adaptMissionThreadMessagesToAgentEvents } from "./missionThreadEventAdapter"; -import { useMissionPolling } from "./useMissionPolling"; - -type MissionThreadMessageListProps = { - messages: OrchestratorChatMessage[]; - sessionId?: string | null; - showStreamingIndicator?: boolean; - transcriptPollingEnabled?: boolean; - className?: string; - onApproval?: ( - sessionId: string, - itemId: string, - decision: AgentChatApprovalDecision, - responseText?: string | null, - answers?: Record<string, string | string[]>, - ) => void; -}; - -const MISSION_THREAD_HISTORY_MAX_EVENTS = 20_000; -const MISSION_THREAD_TRANSCRIPT_MAX_BYTES = 2_000_000; - -function normalizeInlineText(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function textJoinSeparator(left: string, right: string): string { - if (left.length > 0 && right.length > 0 && !/\s$/.test(left) && !/^\s/.test(right)) return " "; - return ""; -} - -function mergeInlineText(existing: string, incoming: string): string { - if (!existing.length) return incoming; - if (!incoming.length) return existing; - if (existing === incoming) return existing; - if (incoming.startsWith(existing)) return incoming; - if (existing.startsWith(incoming)) return existing; - if (existing.includes(incoming)) return existing; - if (incoming.includes(existing)) return incoming; - return `${existing}${textJoinSeparator(existing, incoming)}${incoming}`; -} - -function collectFilesChangedFromJson(value: string): string[] { - try { - const parsed = JSON.parse(value.trim()); - const record = parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as Record<string, unknown> - : null; - const files = Array.isArray(record?.filesChanged) - ? record.filesChanged - : Array.isArray(record?.files_changed) - ? record.files_changed - : []; - return files - .map((entry) => String(entry ?? "").trim()) - .filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index); - } catch { - return []; - } -} - -function cleanMissionThreadText(value: string): string { - const filesChanged: string[] = []; - const cleaned = value.replace( - /(?:\*{0,2}Changed Files\*{0,2}:?\s*)?```(?:json)?\s*([\s\S]*?)```/gi, - (match, body: string) => { - const files = collectFilesChangedFromJson(body); - if (files.length === 0) return match; - filesChanged.push(...files); - return ""; - }, - ) - .replace( - /\s*\*{0,2}\s*Changed\s+Files\s*\*{0,2}:?\s*```(?:json)?[\s\S]*$/i, - "", - ) - .replace( - /\s*\*{0,2}\s*Tests\s+Run\s*\*{0,2}:?\s*```(?:json)?[\s\S]*$/i, - "", - ) - .replace( - /\s*\*{0,2}\s*(?:Risks\s*\/\s*Notes|Risks|Notes)\s*\*{0,2}:?[\s\S]*$/i, - "", - ) - .replace(/\s*\*{0,2}Changed Files\*{0,2}:?\s*```(?:json)?\s*$/i, "") - .replace(/[ \t]{2,}/g, " ") - .replace(/\n{3,}/g, "\n\n") - .trim(); - - const uniqueFiles = filesChanged.filter((entry, index, entries) => entries.indexOf(entry) === index); - if (uniqueFiles.length === 0) return cleaned; - return [ - cleaned, - `Changed files:\n- ${uniqueFiles.join("\n- ")}`, - ].filter(Boolean).join("\n\n"); -} - -function pickLaterTimestamp(left: string, right: string): string { - const leftMs = Date.parse(left); - const rightMs = Date.parse(right); - if (Number.isFinite(leftMs) && Number.isFinite(rightMs)) return rightMs >= leftMs ? right : left; - if (Number.isFinite(rightMs)) return right; - return left; -} - -function provenanceRichness(envelope: AgentChatEventEnvelope): number { - return [ - envelope.provenance?.messageId, - envelope.provenance?.threadId, - envelope.provenance?.role, - envelope.provenance?.targetKind, - envelope.provenance?.sourceSessionId, - envelope.provenance?.attemptId, - envelope.provenance?.stepKey, - envelope.provenance?.laneId, - envelope.provenance?.runId, - ].filter((value) => typeof value === "string" && value.trim().length > 0).length; -} - -function choosePreferredEnvelope( - existing: AgentChatEventEnvelope, - incoming: AgentChatEventEnvelope, -): AgentChatEventEnvelope { - const existingRichness = provenanceRichness(existing); - const incomingRichness = provenanceRichness(incoming); - if (incomingRichness !== existingRichness) return incomingRichness > existingRichness ? incoming : existing; - const existingMs = Date.parse(existing.timestamp); - const incomingMs = Date.parse(incoming.timestamp); - if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs !== existingMs) { - return incomingMs > existingMs ? incoming : existing; - } - return incoming; -} - -function mergeDuplicateEnvelope( - existing: AgentChatEventEnvelope, - incoming: AgentChatEventEnvelope, -): AgentChatEventEnvelope { - const preferred = choosePreferredEnvelope(existing, incoming); - const provenance = - provenanceRichness(incoming) >= provenanceRichness(existing) - ? incoming.provenance ?? existing.provenance - : existing.provenance ?? incoming.provenance; - - if (existing.event.type === "text" && incoming.event.type === "text") { - const preferredEvent = preferred.event as Extract<AgentChatEvent, { type: "text" }>; - return { - ...preferred, - timestamp: pickLaterTimestamp(existing.timestamp, incoming.timestamp), - provenance, - event: { - ...preferredEvent, - turnId: incoming.event.turnId ?? existing.event.turnId, - itemId: incoming.event.itemId ?? existing.event.itemId, - text: mergeInlineText(existing.event.text, incoming.event.text), - }, - }; - } - - if (existing.event.type === "reasoning" && incoming.event.type === "reasoning") { - const preferredEvent = preferred.event as Extract<AgentChatEvent, { type: "reasoning" }>; - return { - ...preferred, - timestamp: pickLaterTimestamp(existing.timestamp, incoming.timestamp), - provenance, - event: { - ...preferredEvent, - turnId: incoming.event.turnId ?? existing.event.turnId, - itemId: incoming.event.itemId ?? existing.event.itemId, - summaryIndex: incoming.event.summaryIndex ?? existing.event.summaryIndex, - text: mergeInlineText(existing.event.text, incoming.event.text), - }, - }; - } - - if (existing.event.type === "command" && incoming.event.type === "command") { - const preferredEvent = preferred.event as Extract<AgentChatEvent, { type: "command" }>; - return { - ...preferred, - timestamp: pickLaterTimestamp(existing.timestamp, incoming.timestamp), - provenance, - event: { - ...preferredEvent, - output: mergeInlineText(existing.event.output, incoming.event.output), - exitCode: incoming.event.exitCode ?? existing.event.exitCode, - durationMs: incoming.event.durationMs ?? existing.event.durationMs, - status: incoming.event.status ?? existing.event.status, - }, - }; - } - - if (existing.event.type === "file_change" && incoming.event.type === "file_change") { - const preferredEvent = preferred.event as Extract<AgentChatEvent, { type: "file_change" }>; - return { - ...preferred, - timestamp: pickLaterTimestamp(existing.timestamp, incoming.timestamp), - provenance, - event: { - ...preferredEvent, - diff: mergeInlineText(existing.event.diff, incoming.event.diff), - status: incoming.event.status ?? existing.event.status, - }, - }; - } - - if (existing.event.type === "tool_result" && incoming.event.type === "tool_result") { - const preferredEvent = preferred.event as Extract<AgentChatEvent, { type: "tool_result" }>; - return { - ...preferred, - timestamp: pickLaterTimestamp(existing.timestamp, incoming.timestamp), - provenance, - event: { - ...preferredEvent, - result: eventHasPayload(incoming.event.result) ? incoming.event.result : existing.event.result, - status: incoming.event.status ?? existing.event.status, - }, - }; - } - - return { - ...preferred, - timestamp: pickLaterTimestamp(existing.timestamp, incoming.timestamp), - provenance, - }; -} - -export function buildMissionThreadEventMergeKey(envelope: AgentChatEventEnvelope): string { - const event = envelope.event; - const baseParts = [envelope.sessionId, event.type]; - const turnId = "turnId" in event && typeof event.turnId === "string" && event.turnId.trim().length > 0 - ? event.turnId - : null; - const itemId = "itemId" in event && typeof event.itemId === "string" && event.itemId.trim().length > 0 - ? event.itemId - : null; - const messageId = typeof envelope.provenance?.messageId === "string" && envelope.provenance.messageId.trim().length > 0 - ? envelope.provenance.messageId - : null; - - switch (event.type) { - case "reasoning": - if (itemId) return [...baseParts, "item", itemId].join("::"); - if (turnId) return [...baseParts, "turn", turnId].join("::"); - if (messageId) return [...baseParts, "message", messageId].join("::"); - return [...baseParts, normalizeInlineText(event.text)].join("::"); - case "text": - if (turnId) return [...baseParts, "turn", turnId].join("::"); - if (itemId) return [...baseParts, "item", itemId].join("::"); - if (messageId) return [...baseParts, "message", messageId].join("::"); - return [...baseParts, normalizeInlineText(event.text)].join("::"); - case "tool_call": - case "tool_result": - return [...baseParts, turnId ?? "turn", event.tool, itemId ?? "item"].join("::"); - case "command": - return [...baseParts, turnId ?? "turn", itemId ?? "item", event.command, event.cwd].join("::"); - case "file_change": - return [...baseParts, turnId ?? "turn", itemId ?? "item", event.path].join("::"); - case "plan": - return [...baseParts, turnId ?? "turn"].join("::"); - case "approval_request": - return [...baseParts, turnId ?? "turn", itemId ?? "item"].join("::"); - case "status": - return [...baseParts, turnId ?? "turn", event.turnStatus, normalizeInlineText(event.message ?? "")].join("::"); - case "delegation_state": - return [ - ...baseParts, - turnId ?? "turn", - event.contract.contractId, - event.contract.status, - normalizeInlineText(event.message ?? ""), - ].join("::"); - case "activity": - return [...baseParts, turnId ?? "turn", event.activity, normalizeInlineText(event.detail ?? "")].join("::"); - case "error": - if (itemId) return [...baseParts, turnId ?? "turn", itemId].join("::"); - return [...baseParts, turnId ?? "turn", normalizeInlineText(event.message)].join("::"); - case "done": - return [...baseParts, event.turnId, event.status].join("::"); - case "step_boundary": - return [...baseParts, turnId ?? "turn", String(event.stepNumber)].join("::"); - case "user_message": - if (turnId) return [...baseParts, "turn", turnId].join("::"); - if (messageId) return [...baseParts, "message", messageId].join("::"); - return [...baseParts, normalizeInlineText(event.text)].join("::"); - default: - return [...baseParts, envelope.timestamp].join("::"); - } -} - -function shouldSuppressLowSignalEphemeralEvent(envelope: AgentChatEventEnvelope): boolean { - if (envelope.provenance?.messageId) return false; - const event = envelope.event; - return (event.type === "text" || event.type === "reasoning") && looksLikeLowSignalNoise(event.text); -} - -const INTERNAL_COORDINATOR_DISPLAY_TEXT = new Set([ - "Start mission coordination.", - "Continue mission coordination.", -]); - -function looksLikeInternalCoordinatorPrompt(text: string): boolean { - const trimmed = text.trim(); - return trimmed.startsWith("## ADE Mission-Control Tool Transport") - || trimmed.startsWith("You are ADE's mission lead for a software engineering mission.") - || trimmed.startsWith("RECOVERED TOOL CALLS EXECUTED"); -} - -function isCoordinatorSessionId(value: string | null | undefined): boolean { - return typeof value === "string" && value.trim().startsWith("coordinator:"); -} - -function isCoordinatorEnvelope(envelope: AgentChatEventEnvelope): boolean { - return envelope.provenance?.targetKind === "coordinator" - || isCoordinatorSessionId(envelope.sessionId) - || isCoordinatorSessionId(envelope.provenance?.sourceSessionId); -} - -function looksLikeCoordinatorReplayMessage(value: string | null | undefined): boolean { - return typeof value === "string" - && value.includes("coordinator wrote mission-control calls as text"); -} - -function looksLikeCleanCodexAppServerExit(value: string | null | undefined): boolean { - return typeof value === "string" - && /^Codex app-server exited \(code=0, signal=null\)\.?$/i.test(value.trim()); -} - -function looksLikeMissionControlTransportText(value: string): boolean { - const lines = value - .trim() - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const firstPayloadLine = lines.find((line) => !line.startsWith("```")); - const match = /^\/\/\s*([A-Za-z0-9_.:-]+)/.exec(firstPayloadLine ?? ""); - if (!match) return false; - const label = match[1] ?? ""; - return /^(?:multi_tool_use\.)?parallel$/i.test(label) || isMissionControlToolName(label); -} - -function looksLikeGenericTurnStatus(value: string | null | undefined): boolean { - return typeof value === "string" - && /^(Turn started|Turn completed)\.?$/i.test(value.trim()); -} - -function looksLikeInternalValidationControlMessage(value: string | null | undefined): boolean { - if (typeof value !== "string") return false; - const normalized = normalizeInlineText(value); - return /\bValidation System:\s*Required validation is missing\b/i.test(normalized) - || /\|\s*next:\s*"[^"]+"\s*—\s*triggering plan adjustment\b/i.test(normalized); -} - -function looksLikeDanglingTextFragment(value: string): boolean { - const trimmed = normalizeInlineText(value); - if (trimmed.length === 0 || trimmed.length >= 32) return false; - if (/[.!?:;]$/.test(trimmed)) return false; - return /\b(?:a|an|and|at|for|from|in|of|or|the|then|to|with)$/i.test(trimmed); -} - -function hasMissionVisibleDoneDetails(event: Extract<AgentChatEvent, { type: "done" }>): boolean { - const usage = event.usage; - return [ - usage?.inputTokens, - usage?.outputTokens, - usage?.cacheReadTokens, - usage?.cacheCreationTokens, - event.costUsd, - ].some((value) => typeof value === "number" && value > 0) - || event.runtime === "cloud"; -} - -function shouldSuppressMissionControlToolEvent(envelope: AgentChatEventEnvelope): boolean { - const event = envelope.event; - if (event.type !== "tool_call" && event.type !== "tool_result") return false; - const itemId = typeof event.itemId === "string" ? event.itemId : ""; - if (itemId.startsWith("mission-control-text:")) return true; - return isMissionControlToolName(event.tool) && isCoordinatorEnvelope(envelope); -} - -export function shouldSuppressMissionThreadEvent(envelope: AgentChatEventEnvelope): boolean { - const event = envelope.event; - if (shouldSuppressMissionControlToolEvent(envelope)) return true; - if (event.type === "user_message") { - const displayText = event.displayText?.trim(); - if (displayText && INTERNAL_COORDINATOR_DISPLAY_TEXT.has(displayText)) return true; - if (looksLikeInternalCoordinatorPrompt(event.text)) return true; - } - if (event.type === "status") { - if (looksLikeGenericTurnStatus(event.message)) return true; - if (looksLikeCoordinatorReplayMessage(event.message)) return true; - if (looksLikeCleanCodexAppServerExit(event.message)) return true; - if (looksLikeInternalValidationControlMessage(event.message)) return true; - } - if (event.type === "error" && looksLikeCleanCodexAppServerExit(event.message)) return true; - if (event.type === "done" && event.status === "completed" && !hasMissionVisibleDoneDetails(event)) return true; - if (event.type === "text" || event.type === "reasoning") { - if (event.text.trim() === "Agent event") return true; - if (event.type === "text" && looksLikeDanglingTextFragment(event.text)) return true; - if (looksLikeInternalValidationControlMessage(event.text)) return true; - if (looksLikeMissionControlTransportText(event.text)) return true; - } - return shouldSuppressLowSignalEphemeralEvent(envelope); -} - -export function mergeMissionThreadEvents( - fallbackEvents: AgentChatEventEnvelope[], - sessionEvents: AgentChatEventEnvelope[] | null, -): AgentChatEventEnvelope[] { - const merged = new Map<string, AgentChatEventEnvelope>(); - for (const event of fallbackEvents) { - merged.set(buildMissionThreadEventMergeKey(event), event); - } - for (const event of sessionEvents ?? []) { - const key = buildMissionThreadEventMergeKey(event); - const existing = merged.get(key); - merged.set(key, existing ? mergeDuplicateEnvelope(existing, event) : event); - } - return [...merged.values()].filter((entry) => !shouldSuppressMissionThreadEvent(entry)).sort((left, right) => { - const delta = Date.parse(left.timestamp) - Date.parse(right.timestamp); - return Number.isFinite(delta) && delta !== 0 - ? delta - : buildMissionThreadEventMergeKey(left).localeCompare(buildMissionThreadEventMergeKey(right)); - }); -} - -function filterLowSignalStructuredEvents(events: AgentChatEventEnvelope[]): AgentChatEventEnvelope[] { - const filtered = events.map((envelope) => { - const event = envelope.event; - if (event.type === "text" || event.type === "reasoning") { - const text = cleanMissionThreadText(event.text); - if (text !== event.text) { - return { - ...envelope, - event: { - ...event, - text, - }, - }; - } - } - return envelope; - }).filter((envelope) => { - if (shouldSuppressMissionThreadEvent(envelope)) return false; - const event = envelope.event; - if ((event.type === "text" || event.type === "reasoning") && looksLikeLowSignalNoise(event.text)) { - return false; - } - if (event.type === "activity") { - const detail = normalizeInlineText(event.detail ?? ""); - if (!detail.length) return false; - if (event.activity === "thinking" && /^thinking through the answer\.?$/i.test(detail)) return false; - if (event.activity === "thinking" && looksLikeLowSignalNoise(detail)) return false; - } - return true; - }); - - return filtered.filter((envelope, index, entries) => { - if (envelope.event.type !== "activity") return true; - const previous = entries[index - 1]; - if (!previous || previous.event.type !== "activity") return true; - return previous.event.activity !== envelope.event.activity - || normalizeInlineText(previous.event.detail ?? "") !== normalizeInlineText(envelope.event.detail ?? ""); - }); -} - -export const MissionThreadMessageList = React.memo(function MissionThreadMessageList({ - messages, - sessionId = null, - showStreamingIndicator = false, - transcriptPollingEnabled = true, - className, - onApproval, -}: MissionThreadMessageListProps) { - const [sessionEvents, setSessionEvents] = useState<AgentChatEventEnvelope[] | null>(null); - const fallbackEvents = useMemo(() => adaptMissionThreadMessagesToAgentEvents(messages), [messages]); - const lastTranscriptRawRef = useRef<string | null>(null); - const transcriptRequestIdRef = useRef(0); - - useEffect(() => { - setSessionEvents(null); - lastTranscriptRawRef.current = null; - transcriptRequestIdRef.current += 1; - }, [sessionId]); - - const refreshSessionTranscript = useCallback(() => { - const trimmedSessionId = sessionId?.trim() ?? ""; - if (!trimmedSessionId) { - setSessionEvents(null); - lastTranscriptRawRef.current = null; - return; - } - const requestId = ++transcriptRequestIdRef.current; - - const applyEvents = (events: AgentChatEventEnvelope[]) => { - if (transcriptRequestIdRef.current !== requestId) return; - setSessionEvents(events); - }; - - (async () => { - let parsed: AgentChatEventEnvelope[] | null = null; - try { - if (typeof window.ade.agentChat?.getEventHistory === "function") { - const snapshot = await window.ade.agentChat.getEventHistory({ - sessionId: trimmedSessionId, - maxEvents: MISSION_THREAD_HISTORY_MAX_EVENTS, - }); - const historyEvents = Array.isArray(snapshot?.events) ? snapshot.events : []; - if (snapshot?.sessionId === trimmedSessionId && (snapshot.sessionFound === true || historyEvents.length > 0)) { - parsed = historyEvents.filter((entry) => entry.sessionId === trimmedSessionId); - } - } - } catch { - parsed = null; - } - - if (parsed) { - applyEvents(parsed); - return; - } - - try { - const raw = await window.ade.sessions.readTranscriptTail({ - sessionId: trimmedSessionId, - maxBytes: MISSION_THREAD_TRANSCRIPT_MAX_BYTES, - raw: true, - }); - if (raw === lastTranscriptRawRef.current) return; - lastTranscriptRawRef.current = raw; - applyEvents(parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === trimmedSessionId)); - } catch { - applyEvents([]); - } - })(); - }, [sessionId]); - - useEffect(() => { - if (!transcriptPollingEnabled) return; - refreshSessionTranscript(); - }, [refreshSessionTranscript, transcriptPollingEnabled]); - - useMissionPolling(refreshSessionTranscript, 4_000, Boolean(sessionId && transcriptPollingEnabled)); - - const events = useMemo(() => { - return filterLowSignalStructuredEvents( - rewriteMissionControlTextToolEvents(mergeMissionThreadEvents(fallbackEvents, sessionEvents)), - ); - }, [fallbackEvents, sessionEvents]); - const subagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(events), [events]); - const pendingInput = useMemo<DerivedPendingInput | null>(() => derivePendingInputRequests(events)[0] ?? null, [events]); - - return ( - <> - <div className="flex h-full min-h-0 min-w-0 max-w-full flex-col overflow-hidden"> - <AgentChatMessageList - key={sessionId ?? "mission-feed"} - events={events} - showStreamingIndicator={showStreamingIndicator} - className={className} - surfaceMode={sessionId ? "mission-thread" : "mission-feed"} - onApproval={onApproval - ? (itemId, decision, responseText, answers) => { - const request = pendingInput?.itemId === itemId - ? pendingInput - : derivePendingInputRequests(events).find((entry) => entry.itemId === itemId) ?? null; - if (!request) return; - onApproval(request.sessionId, itemId, decision, responseText, answers); - } - : undefined} - /> - {subagentSnapshots.length ? ( - <ChatSubagentsPanel - snapshots={subagentSnapshots} - events={events} - /> - ) : null} - </div> - </> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionsHomeDashboard.tsx b/apps/desktop/src/renderer/components/missions/MissionsHomeDashboard.tsx deleted file mode 100644 index 0e770843f..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionsHomeDashboard.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from "react"; -import { Plus } from "@phosphor-icons/react"; -import type { MissionDashboardSnapshot } from "../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT, primaryButton, outlineButton } from "../lanes/laneDesignTokens"; -import { formatDurationMs } from "../../lib/format"; - -type ActiveDashboardEntry = MissionDashboardSnapshot["active"][number]; - -function describeActiveWorkers(entry: ActiveDashboardEntry): string { - if (entry.activeWorkers > 0) return `${entry.activeWorkers} active`; - const { total, completed } = entry.phaseProgress; - if (total > 0 && completed >= total) return "no active workers"; - return "0 active"; -} - -export const MissionsHomeDashboard = React.memo(function MissionsHomeDashboard({ - snapshot, - onNewMission, - onViewMission -}: { - snapshot: MissionDashboardSnapshot | null; - onNewMission: () => void; - onViewMission: (missionId: string) => void; -}) { - if (!snapshot) { - return ( - <div className="flex h-full items-center justify-center text-xs" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - Loading mission dashboard... - </div> - ); - } - - return ( - <div className="flex h-full min-h-0 flex-col p-4 gap-3 overflow-auto"> - <div className="flex items-center justify-between"> - <div className="text-sm font-bold" style={{ color: COLORS.textPrimary, fontFamily: SANS_FONT }}>MISSIONS</div> - <button style={primaryButton()} onClick={onNewMission}> - <Plus size={14} /> - NEW MISSION - </button> - </div> - - <div className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Active Missions</div> - <div className="mt-2 space-y-2"> - {snapshot.active.length === 0 ? ( - <div className="text-xs" style={{ color: COLORS.textDim }}>No active missions.</div> - ) : snapshot.active.map((entry) => ( - <button - key={entry.mission.id} - className="w-full text-left p-2" - style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }} - onClick={() => onViewMission(entry.mission.id)} - > - <div className="flex items-center justify-between gap-2"> - <span className="truncate text-xs font-medium" style={{ color: COLORS.textPrimary }}>{entry.mission.title}</span> - <span className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>{entry.phaseProgress.pct}%</span> - </div> - <div className="mt-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {entry.phaseName ?? "Phase"} · {describeActiveWorkers(entry)} · {formatDurationMs(entry.elapsedMs)} - </div> - <div className="mt-1 h-1.5 w-full" style={{ background: COLORS.pageBg }}> - <div className="h-full" style={{ width: `${entry.phaseProgress.pct}%`, background: COLORS.accent }} /> - </div> - </button> - ))} - </div> - </div> - - <div className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Recent Missions</div> - <div className="mt-2 space-y-1.5"> - {snapshot.recent.length === 0 ? ( - <div className="text-xs" style={{ color: COLORS.textDim }}>No recent missions.</div> - ) : snapshot.recent.map((entry) => ( - <div key={entry.mission.id} className="flex items-center gap-2 px-2 py-1.5" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: COLORS.textPrimary }}>{entry.mission.title}</span> - <span className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>{formatDurationMs(entry.durationMs)}</span> - <button - style={outlineButton()} - aria-label={`View mission ${entry.mission.title}`} - onClick={() => onViewMission(entry.mission.id)} - > - VIEW - </button> - </div> - ))} - </div> - </div> - - <div className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Stats (7d)</div> - <div className="mt-2 grid grid-cols-2 gap-2"> - <div className="p-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Missions</div> - <div className="text-sm font-semibold" style={{ color: COLORS.textPrimary }}>{snapshot.weekly.missions}</div> - </div> - <div className="p-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Success</div> - <div className="text-sm font-semibold" style={{ color: COLORS.textPrimary }}>{Math.round((snapshot.weekly.successRate ?? 0) * 100)}%</div> - </div> - <div className="p-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Avg Duration</div> - <div className="text-sm font-semibold" style={{ color: COLORS.textPrimary }}>{formatDurationMs(snapshot.weekly.avgDurationMs)}</div> - </div> - <div className="p-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}>Est. Cost</div> - <div className="text-sm font-semibold" style={{ color: COLORS.textPrimary }}>${(snapshot.weekly.totalCostUsd ?? 0).toFixed(2)}</div> - </div> - </div> - </div> - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/MissionsPage.tsx b/apps/desktop/src/renderer/components/missions/MissionsPage.tsx deleted file mode 100644 index 4707ee7dc..000000000 --- a/apps/desktop/src/renderer/components/missions/MissionsPage.tsx +++ /dev/null @@ -1,572 +0,0 @@ -import { useCallback, useMemo, useEffect, useRef, useState } from "react"; -import { LazyMotion, domAnimation } from "motion/react"; -import { - ArrowRight, - CalendarBlank, - CheckCircle, - Compass, - FlagBanner, - GitBranch, - ListChecks, -} from "@phosphor-icons/react"; -import { Group, Panel, Separator } from "react-resizable-panels"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { useShallow } from "zustand/react/shallow"; -import { useAppStore } from "../../state/appStore"; -import { MONO_FONT } from "../lanes/laneDesignTokens"; - -/* ── Store & extracted components ── */ -import { - MissionsStoreProvider, - createMissionsStore, - useMissionsStore, - useMissionsStoreApi, - type MissionsStore, - type MissionsStoreApi, -} from "./useMissionsStore"; -import { MissionSidebar } from "./MissionSidebar"; -import { MissionDetailView } from "./MissionDetailView"; -import { ManageMissionDialog, MissionContextMenu } from "./ManageMissionDialog"; -import { MissionCreateDialogHost } from "./MissionCreateDialogHost"; -import { MissionSettingsDialog } from "./MissionSettingsDialog"; -import { useMissionPolling } from "./useMissionPolling"; -import { useMissionRunView } from "./useMissionRunView"; -import { filterExecutionSteps } from "./missionHelpers"; -import { MissionActiveProvider } from "./MissionActiveContext"; - -import type { CreateDraft, CreateMissionDefaults } from "./CreateMissionDialog"; -import { buildMissionLaunchRequest, prewarmCreateMissionDialogCache } from "./CreateMissionDialog"; - -/* Re-export helpers used by tests */ -export { collapsePlannerStreamMessages, resolveStepHeartbeatAt } from "./missionHelpers"; - -const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "canceled"]); - -type AppPackagingState = "checking" | "packaged" | "dev"; - -function ProductionMissionsComingSoon() { - const navigate = useNavigate(); - const previewItems = [ - { - icon: Compass, - title: "Mission planning", - body: "Turn a larger goal into phases, worker lanes, checkpoints, and proof requirements before agents start.", - }, - { - icon: GitBranch, - title: "Lane orchestration", - body: "Coordinate multi-agent work across isolated lanes, then bring results back with reviewable handoffs.", - }, - { - icon: ListChecks, - title: "Human checkpoints", - body: "Pause when ADE needs operator input, budget approval, or a decision before external side effects happen.", - }, - { - icon: CheckCircle, - title: "Artifacts and closeout", - body: "Collect logs, summaries, screenshots, and implementation proof into one mission record.", - }, - ]; - - return ( - <div className="h-full overflow-y-auto bg-bg text-fg"> - <div className="mx-auto flex min-h-full max-w-6xl flex-col justify-center px-6 py-10"> - <div className="grid gap-8 lg:grid-cols-[0.9fr_1.1fr] lg:items-center"> - <section className="space-y-6"> - <div className="inline-flex items-center gap-2 rounded border border-emerald-400/30 bg-emerald-400/10 px-3 py-1 font-mono text-[10px] font-bold uppercase tracking-[1px] text-emerald-200"> - <FlagBanner size={14} weight="regular" /> - Coming soon - </div> - - <div> - <h1 className="text-4xl font-semibold tracking-normal text-[#F5FAFF]">Missions are almost ready</h1> - <p className="mt-4 max-w-xl text-sm leading-6 text-muted-fg"> - Missions are ADE's multi-step orchestration layer for work that is bigger than one chat thread: - planning, delegation, lane execution, intervention, and proof captured as one run. - </p> - </div> - - <div className="rounded-lg border border-white/[0.08] bg-white/[0.03] p-4"> - <div className="flex items-start gap-3"> - <CalendarBlank size={18} weight="regular" className="mt-0.5 shrink-0 text-[#7DD3FC]" /> - <div> - <div className="text-sm font-semibold text-[#F5FAFF]">Production access is paused</div> - <p className="mt-1 text-sm leading-6 text-muted-fg"> - The tab is visible so teams can see where missions fit, but creation and live runs stay disabled - in packaged builds until the orchestration flow is production-ready. - </p> - </div> - </div> - </div> - - <button - type="button" - onClick={() => navigate("/automations")} - className="inline-flex h-9 items-center gap-2 rounded border border-white/[0.12] bg-white/[0.04] px-4 font-mono text-[10px] font-bold uppercase tracking-[1px] text-[#D8E3F2] transition-colors hover:border-[#7DD3FC]/40 hover:text-[#F5FAFF]" - > - Review automations - <ArrowRight size={13} weight="regular" /> - </button> - </section> - - <section className="grid gap-3 sm:grid-cols-2"> - {previewItems.map(({ icon: Icon, title, body }) => ( - <article key={title} className="rounded-lg border border-white/[0.08] bg-black/15 p-4"> - <Icon size={18} weight="regular" className="text-[#A78BFA]" /> - <div className="mt-3 text-sm font-semibold text-[#F5FAFF]">{title}</div> - <p className="mt-2 text-xs leading-5 text-muted-fg">{body}</p> - </article> - ))} - </section> - </div> - </div> - </div> - ); -} - -function MissionsProductionGate({ active = true, children }: { active?: boolean; children: React.ReactElement }) { - const [state, setState] = useState<AppPackagingState>("checking"); - - useEffect(() => { - if (!active) return; - let cancelled = false; - window.ade.app.getInfo().then( - (info) => { - if (!cancelled) setState(info.isPackaged ? "packaged" : "dev"); - }, - () => { - if (!cancelled) setState("dev"); - }, - ); - return () => { - cancelled = true; - }; - }, [active]); - - if (state === "checking") { - return ( - <div className="flex h-full min-w-0 flex-col bg-bg"> - <div className="flex flex-1 flex-col items-center justify-center gap-3"> - <div className="h-4 w-48 animate-pulse rounded-md bg-white/[0.06]" /> - <div className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted-fg"> - Checking mission availability... - </div> - </div> - </div> - ); - } - - if (state === "packaged") return <ProductionMissionsComingSoon />; - - return children; -} - -/* ── Sidebar width persistence (VAL-UX-010) ── */ -const SIDEBAR_WIDTH_KEY = "ade.missions.sidebarWidth"; -const SIDEBAR_MIN_PX = 200; -const SIDEBAR_MAX_PX = 400; -const SIDEBAR_DEFAULT_PX = 248; -const SIDEBAR_COLLAPSE_THRESHOLD = 900; - -/** Read persisted sidebar width (in pixels) from localStorage. */ -function readPersistedSidebarPx(): number { - try { - const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY); - if (raw) { - const n = Number(raw); - if (Number.isFinite(n) && n >= SIDEBAR_MIN_PX && n <= SIDEBAR_MAX_PX) return n; - } - } catch { /* ignore */ } - return SIDEBAR_DEFAULT_PX; -} - -/** Persist sidebar width (in pixels) to localStorage. */ -function persistSidebarPx(px: number): void { - try { - localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px))); - } catch { /* ignore */ } -} - -/* ── Fine-grained page-level selector (VAL-ARCH-008) ── */ -const selectPageData = (s: MissionsStore) => ({ - loading: s.loading, - missionSettingsOpen: s.missionSettingsOpen, - missionSettingsBusy: s.missionSettingsBusy, - missionSettingsError: s.missionSettingsError, - missionSettingsNotice: s.missionSettingsNotice, - missionSettingsDraft: s.missionSettingsDraft, -}); - -/* ════════════════════ MAIN COMPONENT ════════════════════ */ - -function MissionsWorkspace({ active = true }: { active?: boolean } = {}) { - const [searchParams, setSearchParams] = useSearchParams(); - const missionsStore = useMissionsStoreApi(); - const lanes = useAppStore((s) => s.lanes); - const mappedLanes = useMemo(() => lanes.map((l) => ({ id: l.id, name: l.name })), [lanes]); - const appliedQueryMissionIdRef = useRef<string | null>(null); - const pendingQueryMissionIdRef = useRef<string | null>(null); - - /* ── Fine-grained store slice via useShallow (VAL-ARCH-008) ── */ - const { - loading, - missionSettingsOpen, - missionSettingsBusy, - missionSettingsError, - missionSettingsNotice, - missionSettingsDraft, - } = useMissionsStore(useShallow(selectPageData)); - - /* ── Default lane for create dialog ── */ - const defaultCreateLaneId = useMemo( - () => lanes.find((l) => l.laneType === "primary")?.id ?? lanes[0]?.id ?? null, - [lanes], - ); - - const createMissionDefaults = useMemo<CreateMissionDefaults>( - () => ({ - orchestratorModel: missionSettingsDraft.defaultOrchestratorModel, - permissionConfig: missionSettingsDraft.permissionConfig, - smartBudget: missionSettingsDraft.smartBudget, - }), - [missionSettingsDraft], - ); - - /* ── Initial data load ── */ - useEffect(() => { - if (!active) return; - let cancelled = false; - const store = missionsStore.getState(); - void store.refreshMissionList({ preserveSelection: true }); - const dashboardTimer = window.setTimeout(() => { - if (cancelled) return; - void store.loadDashboard(); - }, 450); - return () => { - cancelled = true; - window.clearTimeout(dashboardTimer); - }; - }, [active, missionsStore]); - - useEffect(() => { - if (!active) return; - let cancelled = false; - const timer = window.setTimeout(() => { - if (cancelled) return; - void missionsStore.getState().loadMissionSettings(); - }, 900); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [active, missionsStore]); - - useEffect(() => { - if (!active) return; - let cancelled = false; - const timer = window.setTimeout(() => { - window.ade.orchestrator.getModelCapabilities().then( - (result) => { if (!cancelled) missionsStore.getState().setModelCapabilities(result); }, - () => { if (!cancelled) missionsStore.getState().setModelCapabilities(null); }, - ); - }, 1_200); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [active, missionsStore]); - - useEffect(() => { - if (!active) return; - let cancelled = false; - const timer = window.setTimeout(() => { - if (cancelled) return; - void prewarmCreateMissionDialogCache().catch(() => {}); - }, 1_500); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [active]); - - /* ── Event subscriptions — delegated to store (VAL-ARCH-007) ── */ - useEffect(() => { - if (!active) return; - const cleanup = missionsStore.getState().initEventSubscriptions(); - return cleanup; - }, [active, missionsStore]); - - /* ── Selection change via consolidated getFullMissionView (VAL-ARCH-004) ── */ - const selectedMissionId = useMissionsStore((s) => s.selectedMissionId); - useEffect(() => { - if (!active) return; - void missionsStore.getState().selectMission(selectedMissionId); - }, [active, missionsStore, selectedMissionId]); - - useEffect(() => { - if (!active) return; - const missionParam = (searchParams.get("missionId") ?? "").trim(); - if (!missionParam) { - appliedQueryMissionIdRef.current = null; - pendingQueryMissionIdRef.current = null; - return; - } - if (appliedQueryMissionIdRef.current === missionParam) return; - appliedQueryMissionIdRef.current = missionParam; - pendingQueryMissionIdRef.current = missionParam; - void missionsStore.getState().selectMission(missionParam).finally(() => { - const selectedMission = missionsStore.getState().selectedMission; - if (pendingQueryMissionIdRef.current === missionParam && selectedMission?.id !== missionParam) { - pendingQueryMissionIdRef.current = null; - } - }); - }, [active, missionsStore, searchParams]); - - useEffect(() => { - if (!active || !selectedMissionId) return; - const pendingQueryMissionId = pendingQueryMissionIdRef.current; - if (pendingQueryMissionId) { - if (pendingQueryMissionId === selectedMissionId) { - pendingQueryMissionIdRef.current = null; - } - return; - } - const missionParam = (searchParams.get("missionId") ?? "").trim(); - if (missionParam === selectedMissionId) return; - const nextParams = new URLSearchParams(searchParams); - nextParams.set("missionId", selectedMissionId); - appliedQueryMissionIdRef.current = selectedMissionId; - setSearchParams(nextParams, { replace: true }); - }, [active, searchParams, selectedMissionId, setSearchParams]); - - /* ── Checkpoint polling via shared coordinator ── */ - const runGraph = useMissionsStore((s) => s.runGraph); - const { runView } = useMissionRunView(selectedMissionId, runGraph?.run.id ?? null, active); - const checkpointPollEnabled = active && Boolean(runGraph && !TERMINAL_RUN_STATUSES.has(runGraph.run.status)); - const checkpointRunId = runGraph?.run.id ?? null; - const refreshCheckpointStatus = useCallback(() => { - const store = missionsStore.getState(); - if (!checkpointRunId) { store.setCheckpointStatus(null); return; } - void window.ade.orchestrator.getCheckpointStatus({ runId: checkpointRunId }).then( - (next) => store.setCheckpointStatus(next), - () => store.setCheckpointStatus(null), - ); - }, [checkpointRunId, missionsStore]); - useMissionPolling(refreshCheckpointStatus, 10_000, checkpointPollEnabled); - useEffect(() => { - if (!active) return; - if (!checkpointPollEnabled) missionsStore.getState().setCheckpointStatus(null); - }, [active, checkpointPollEnabled, missionsStore]); - - /* ── Step selection reconciliation ── */ - useEffect(() => { - if (!active) return; - const steps = runGraph?.steps ?? []; - const selectableSteps = filterExecutionSteps(steps); - const selectionPool = selectableSteps.length > 0 ? selectableSteps : steps; - const store = missionsStore.getState(); - const currentStepId = store.selectedStepId; - if (!steps.length) { - if (currentStepId !== null) store.setSelectedStepId(null); - return; - } - if (currentStepId && selectionPool.some((s) => s.id === currentStepId)) return; - const running = selectionPool.find((s) => s.status === "running"); - store.setSelectedStepId((running ?? selectionPool[0]).id); - }, [active, missionsStore, runGraph]); - - useEffect(() => { - if (!active) return; - missionsStore.getState().setCoordinatorPromptInspector(null); - missionsStore.getState().setWorkerPromptInspector(null); - }, [active, missionsStore, selectedMissionId]); - - /* ── Attention toast notifications (timers owned by store, VAL-ARCH-007) ── */ - const selectedMission = useMissionsStore((s) => s.selectedMission); - const prevMissionStatusRef = useRef<string | null>(null); - const prevOpenInterventionCountRef = useRef<number>(0); - - useEffect(() => { - if (!active) return; - if (!selectedMission) { - prevMissionStatusRef.current = null; - prevOpenInterventionCountRef.current = 0; - return; - } - const prevStatus = prevMissionStatusRef.current; - const prevOpenCount = prevOpenInterventionCountRef.current; - const currentStatus = selectedMission.status; - const currentOpenCount = selectedMission.openInterventions; - prevMissionStatusRef.current = currentStatus; - prevOpenInterventionCountRef.current = currentOpenCount; - if (prevStatus === null) return; - - const store = missionsStore.getState(); - if (currentStatus === "intervention_required" && prevStatus !== "intervention_required") { - store.addAttentionToast("Mission requires intervention", "warning", selectedMission.title, selectedMission.id); - } else if (currentStatus === "failed" && prevStatus !== "failed") { - store.addAttentionToast("Mission has failed", "error", selectedMission.title, selectedMission.id); - } else if (currentOpenCount > prevOpenCount && currentStatus === "in_progress") { - store.addAttentionToast( - `${currentOpenCount - prevOpenCount} new intervention${currentOpenCount - prevOpenCount === 1 ? "" : "s"} opened`, - "warning", - selectedMission.title, - selectedMission.id, - ); - } - }, [active, missionsStore, selectedMission?.status, selectedMission?.openInterventions, selectedMission?.id, selectedMission?.title, selectedMission]); - - useEffect(() => { - return () => missionsStore.getState().cleanupToastTimers(); - }, [missionsStore]); - - /* ── Responsive sidebar collapse (VAL-UX-010) ── */ - const [collapsed, setCollapsed] = useState(false); - useEffect(() => { - const mql = window.matchMedia(`(max-width: ${SIDEBAR_COLLAPSE_THRESHOLD}px)`); - const handler = (e: MediaQueryListEvent | MediaQueryList) => setCollapsed(e.matches); - handler(mql); - mql.addEventListener("change", handler as (e: MediaQueryListEvent) => void); - return () => mql.removeEventListener("change", handler as (e: MediaQueryListEvent) => void); - }, []); - - const defaultSidebarPx = useMemo(() => readPersistedSidebarPx(), []); - - /* ── Mission launch handler ── */ - const handleLaunchMission = useCallback( - async (draft: CreateDraft) => { - const store = missionsStore.getState(); - const prompt = draft.prompt.trim(); - if (!prompt) { store.setError("Mission prompt is required."); return; } - try { - const created = await window.ade.missions.create( - buildMissionLaunchRequest({ - draft, - activePhases: draft.phaseOverride, - defaultLaneId: defaultCreateLaneId, - }), - ); - store.setSelectedMissionId(created.id); - await store.selectMission(created.id); - await store.refreshMissionList({ preserveSelection: true, silent: true }); - store.setError(null); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - store.setError(message); - throw err instanceof Error ? err : new Error(message); - } - }, - [defaultCreateLaneId, missionsStore], - ); - - /* ── Loading screen ── */ - if (loading) { - return ( - <div className="flex h-full min-w-0 flex-col bg-bg"> - <div className="flex flex-col items-center justify-center flex-1 gap-3"> - <div className="animate-pulse flex flex-col items-center gap-2"> - <div className="h-4 w-48 rounded-md bg-white/[0.06]" /> - <div className="h-3 w-32 rounded-md bg-white/[0.04]" /> - </div> - <div - className="text-[10px] font-bold uppercase tracking-widest text-muted-fg" - style={{ fontFamily: MONO_FONT }} - > - LOADING MISSIONS... - </div> - </div> - </div> - ); - } - - /* ════════════════════ RENDER ════════════════════ */ - return ( - <div data-route="missions" className="flex h-full min-h-0 flex-col bg-bg"> - <LazyMotion features={domAnimation}> - <Group - id="missions-layout" - orientation="horizontal" - className="flex h-full min-h-0 bg-bg" - > - {/* Sidebar (resizable 200-400px, VAL-UX-010) */} - {!collapsed && ( - <> - <Panel - id="missions-sidebar" - defaultSize={defaultSidebarPx} - minSize={SIDEBAR_MIN_PX} - maxSize={SIDEBAR_MAX_PX} - onResize={(size) => persistSidebarPx(size.inPixels)} - style={{ overflow: "hidden" }} - > - <MissionSidebar runView={runView} /> - </Panel> - <Separator - id="missions-separator" - className="w-[4px] bg-white/[0.06] hover:bg-[#A78BFA]/20 active:bg-[#A78BFA]/30 transition-colors cursor-col-resize" - /> - </> - )} - - {/* Main workspace */} - <Panel id="missions-detail" minSize="50%"> - <div className="flex flex-1 flex-col min-w-0 h-full bg-bg"> - <MissionDetailView runView={runView} /> - </div> - </Panel> - </Group> - - {/* Context Menu */} - <MissionContextMenu /> - - {/* Manage Mission Dialog */} - <ManageMissionDialog /> - - {/* Create Mission Dialog */} - <MissionCreateDialogHost - lanes={mappedLanes} - defaultLaneId={defaultCreateLaneId} - missionDefaults={createMissionDefaults} - onLaunch={handleLaunchMission} - /> - - {/* Mission Settings Dialog */} - {missionSettingsOpen ? ( - <MissionSettingsDialog - open={missionSettingsOpen} - onClose={() => { - if (missionSettingsBusy) return; - missionsStore.getState().setMissionSettingsOpen(false); - }} - draft={missionSettingsDraft} - onDraftChange={(update) => missionsStore.getState().setMissionSettingsDraft((prev) => ({ ...prev, ...update }))} - onSave={() => void missionsStore.getState().saveMissionSettings()} - busy={missionSettingsBusy} - error={missionSettingsError} - notice={missionSettingsNotice} - /> - ) : null} - </LazyMotion> - </div> - ); -} - -/* Re-export for compatibility: the page was previously a named export */ -export default function MissionsPage({ active = true }: { active?: boolean } = {}) { - const storeRef = useRef<MissionsStoreApi | null>(null); - if (!storeRef.current) { - storeRef.current = createMissionsStore(); - } - return ( - <MissionsStoreProvider store={storeRef.current}> - <MissionActiveProvider active={active}> - <MissionsProductionGate active={active}> - <MissionsWorkspace active={active} /> - </MissionsProductionGate> - </MissionActiveProvider> - </MissionsStoreProvider> - ); -} - -export { MissionsPage }; diff --git a/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.tsx b/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.tsx deleted file mode 100644 index ffcf4d230..000000000 --- a/apps/desktop/src/renderer/components/missions/OrchestratorActivityFeed.tsx +++ /dev/null @@ -1,517 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from "react"; -import type { OrchestratorTimelineEvent } from "../../../shared/types"; -import { cn } from "../ui/cn"; -import { NOISY_EVENT_TYPES, classifyErrorSource, ERROR_SOURCE_COLORS, collapseFeedMessages, type CollapsedFeedMessage } from "./missionHelpers"; -import { useMissionPageActive } from "./MissionActiveContext"; - -type Props = { - runId: string; - initialTimeline: OrchestratorTimelineEvent[]; -}; - -const EVENT_CONFIG: Record<string, { icon: string; color: string; label: string; category: string }> = { - step_status_changed: { icon: "\u25CF", color: "text-blue-400", label: "Step", category: "Steps" }, - step_registered: { icon: "\u25CF", color: "text-blue-400", label: "Step", category: "Steps" }, - step_dependencies_resolved: { icon: "\u25CF", color: "text-blue-400", label: "Step", category: "Steps" }, - step_skipped: { icon: "\u25CF", color: "text-blue-400", label: "Step", category: "Steps" }, - step_superseded: { icon: "\u2248", color: "text-amber-300", label: "Superseded", category: "Steps" }, - autopilot_advance: { icon: "\u25B6", color: "text-violet-400", label: "Autopilot", category: "Workers" }, - autopilot_attempt_start_failed: { icon: "\u25B6", color: "text-red-400", label: "Autopilot", category: "Workers" }, - attempt_started: { icon: "\u25B6", color: "text-violet-400", label: "Attempt", category: "Workers" }, - attempt_completed: { icon: "\u25B6", color: "text-violet-400", label: "Attempt", category: "Workers" }, - attempt_blocked: { icon: "\u25B6", color: "text-amber-400", label: "Attempt", category: "Workers" }, - attempt_retry_scheduled: { icon: "\u25B6", color: "text-amber-400", label: "Retry", category: "Workers" }, - attempt_recovered_after_restart: { icon: "\u25B6", color: "text-cyan-400", label: "Recovery", category: "Workers" }, - worker_message_routed: { icon: "\u2709", color: "text-cyan-300", label: "Message", category: "Workers" }, - worker_status_reported: { icon: "\u25CB", color: "text-sky-300", label: "Status", category: "Workers" }, - worker_result_reported: { icon: "\u2713", color: "text-emerald-300", label: "Result", category: "Workers" }, - claim_acquired: { icon: "\u25A0", color: "text-amber-400", label: "Claim", category: "Workers" }, - claim_released: { icon: "\u25A1", color: "text-amber-300", label: "Release", category: "Workers" }, - claim_expired: { icon: "\u25A1", color: "text-amber-300", label: "Expired", category: "Workers" }, - claim_heartbeat: { icon: "\u25A1", color: "text-amber-200", label: "Heartbeat", category: "Workers" }, - context_snapshot_created: { icon: "\u25C6", color: "text-cyan-400", label: "Snapshot", category: "Quality" }, - context_pressure_warning: { icon: "\u26A0", color: "text-red-400", label: "Pressure", category: "Quality" }, - validation_contract_unfulfilled: { icon: "\u26A0", color: "text-red-400", label: "Validation", category: "Quality" }, - validation_self_check_reminder: { icon: "\u2611", color: "text-amber-300", label: "Validation", category: "Quality" }, - validation_auto_spawned: { icon: "\u2699", color: "text-cyan-300", label: "Validation", category: "Quality" }, - validation_gate_blocked: { icon: "\u26D4", color: "text-amber-400", label: "Validation", category: "Quality" }, - validation_reported: { icon: "\u2714", color: "text-emerald-300", label: "Validation", category: "Quality" }, - validation_escalated: { icon: "\u26A0", color: "text-amber-400", label: "Validation", category: "Quality" }, - integration_chain_started: { icon: "\u2295", color: "text-emerald-400", label: "Merge", category: "Integration" }, - run_status_changed: { icon: "\u25C9", color: "text-green-400", label: "Run", category: "Steps" }, - run_created: { icon: "\u25C9", color: "text-green-400", label: "Run", category: "Steps" }, - run_resumed: { icon: "\u25C9", color: "text-green-400", label: "Run", category: "Steps" }, - run_canceled: { icon: "\u25C9", color: "text-red-400", label: "Run", category: "Steps" }, - phase_transition: { icon: "\u25B8", color: "text-indigo-400", label: "Phase", category: "Steps" }, - plan_revised: { icon: "\u267B", color: "text-indigo-300", label: "Plan", category: "Steps" }, - lane_transfer: { icon: "\u21C4", color: "text-orange-300", label: "Lane", category: "Steps" }, - tool_profiles_updated: { icon: "\u2699", color: "text-purple-300", label: "Tools", category: "Steps" }, - budget_warning: { icon: "\u26A0", color: "text-amber-400", label: "Budget Warning", category: "Quality" }, - budget_hard_cap_triggered: { icon: "\u26D4", color: "text-red-400", label: "Budget Hard Cap", category: "Quality" }, - mission_paused: { icon: "\u23F8", color: "text-amber-400", label: "Mission Paused", category: "Steps" }, - mission_resumed: { icon: "\u25B6", color: "text-emerald-400", label: "Mission Resumed", category: "Steps" }, - intervention_opened: { icon: "\u26A0", color: "text-red-400", label: "Intervention Opened", category: "Quality" }, - intervention_resolved: { icon: "\u2714", color: "text-emerald-400", label: "Intervention Resolved", category: "Quality" }, - recovery_loop_started: { icon: "\u21BB", color: "text-amber-400", label: "Recovery Started", category: "Quality" }, - recovery_loop_exhausted: { icon: "\u26D4", color: "text-red-400", label: "Recovery Exhausted", category: "Quality" }, - fan_out_dispatched: { icon: "\u25B6", color: "text-violet-400", label: "Fan-Out Dispatched", category: "Workers" }, - fan_out_complete: { icon: "\u2713", color: "text-emerald-300", label: "Fan-Out Complete", category: "Workers" }, - run_completing: { icon: "\u25C9", color: "text-green-400", label: "Run Completing", category: "Steps" }, - run_finalized: { icon: "\u25C9", color: "text-green-400", label: "Run Finalized", category: "Steps" }, - run_completion_requested: { icon: "\u25C9", color: "text-green-400", label: "Completion Requested", category: "Steps" }, - run_completion_blocked: { icon: "\u25C9", color: "text-amber-400", label: "Completion Blocked", category: "Steps" }, - run_activated: { icon: "\u25C9", color: "text-green-400", label: "Run Activated", category: "Steps" }, - run_reopened: { icon: "\u25C9", color: "text-green-400", label: "Run Reopened", category: "Steps" }, - integration_chain_stage: { icon: "\u2295", color: "text-emerald-400", label: "Integration Stage", category: "Integration" }, - coordinator_steering: { icon: "\u2709", color: "text-cyan-300", label: "User Steering", category: "Steps" }, - coordinator_broadcast: { icon: "\u2709", color: "text-cyan-300", label: "Coordinator Broadcast", category: "Workers" }, - budget_exceeded: { icon: "\u26D4", color: "text-red-400", label: "Budget Exceeded", category: "Quality" }, - budget_updated: { icon: "\u2699", color: "text-amber-300", label: "Budget Updated", category: "Quality" }, - worker_started: { icon: "\u25B6", color: "text-violet-400", label: "Worker Started", category: "Workers" }, - worker_failed: { icon: "\u2717", color: "text-red-400", label: "Worker Failed", category: "Workers" }, - integration_pr_step_inserted: { icon: "\u2295", color: "text-emerald-400", label: "PR Step Inserted", category: "Integration" }, -}; - -const DEFAULT_CONFIG = { icon: "\u25CB", color: "text-muted-fg", label: "Event", category: "All Events" }; - -const CATEGORY_OPTIONS = ["All Events", "Steps", "Workers", "Quality", "Integration"]; -type Severity = "all" | "warnings" | "errors"; -/** Noisy events filtered from default view. Uses canonical NOISY_EVENT_TYPES (VAL-UX-008). */ -const HIDDEN_MAINTENANCE_EVENT_TYPES = NOISY_EVENT_TYPES; - -function sortTimeline(events: OrchestratorTimelineEvent[]): OrchestratorTimelineEvent[] { - return [...events].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); -} - -function relativeTime(iso: string): string { - const delta = Math.max(0, Date.now() - Date.parse(iso)); - if (delta < 1000) return "now"; - const seconds = Math.floor(delta / 1000); - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - -function formatTimestamp(iso: string): string { - try { - return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - } catch { - return iso; - } -} - -function eventSeverity(ev: OrchestratorTimelineEvent): "error" | "warning" | "info" { - const r = ev.reason.toLowerCase(); - if ( - r.includes("failed") || - r.includes("error") || - ev.eventType === "context_pressure_warning" || - ev.eventType === "validation_contract_unfulfilled" - ) { - return "error"; - } - if ( - r.includes("warning") || - r.includes("blocked") || - r.includes("paused") || - ev.eventType === "validation_self_check_reminder" || - ev.eventType === "validation_gate_blocked" - ) { - return "warning"; - } - return "info"; -} - -/** Adapt OrchestratorTimelineEvent to the { eventType, stepId } shape required by collapseFeedMessages */ -function collapseTimelineEvents(events: OrchestratorTimelineEvent[]): CollapsedFeedMessage<OrchestratorTimelineEvent>[] { - return collapseFeedMessages( - events.map((ev) => ({ ...ev, eventType: ev.eventType, stepId: ev.stepId ?? null })), - ); -} - -export const OrchestratorActivityFeed = React.memo(function OrchestratorActivityFeed({ runId, initialTimeline }: Props) { - const active = useMissionPageActive(); - const [events, setEvents] = useState<OrchestratorTimelineEvent[]>(() => sortTimeline(initialTimeline)); - const [category, setCategory] = useState("All Events"); - const [severity, setSeverity] = useState<Severity>("all"); - const [searchText, setSearchText] = useState(""); - const [showRawEvents, setShowRawEvents] = useState(false); - const [expandedId, setExpandedId] = useState<string | null>(null); - const [autoScroll, setAutoScroll] = useState(true); - const scrollRef = useRef<HTMLDivElement>(null); - const refreshTimerRef = useRef<number | null>(null); - const refreshInFlightRef = useRef(false); - const refreshQueuedRef = useRef(false); - const refreshTokenRef = useRef(0); - - const refreshTimeline = useCallback(async () => { - if (!runId) return; - if (refreshInFlightRef.current) { - refreshQueuedRef.current = true; - return; - } - refreshInFlightRef.current = true; - const token = refreshTokenRef.current; - try { - const latest = await window.ade.orchestrator.listTimeline({ - runId, - limit: 400 - }); - if (token === refreshTokenRef.current) { - setEvents(sortTimeline(latest)); - } - } catch { - // Ignore transient timeline refresh failures; live subscription/polling will retry. - } finally { - refreshInFlightRef.current = false; - if (refreshQueuedRef.current) { - refreshQueuedRef.current = false; - void refreshTimeline(); - } - } - }, [runId]); - - const scheduleTimelineRefresh = useCallback((delayMs = 140) => { - if (refreshTimerRef.current !== null) { - window.clearTimeout(refreshTimerRef.current); - } - refreshTimerRef.current = window.setTimeout(() => { - refreshTimerRef.current = null; - void refreshTimeline(); - }, delayMs); - }, [refreshTimeline]); - - useEffect(() => { - if (!active) return; - refreshTokenRef.current += 1; - setEvents(sortTimeline(initialTimeline)); - void refreshTimeline(); - }, [active, initialTimeline, refreshTimeline]); - - useEffect(() => { - if (!active) return; - const unsub = window.ade.orchestrator.onEvent((ev) => { - if (ev.runId !== runId) return; - scheduleTimelineRefresh(); - }); - return () => { - unsub(); - }; - }, [active, runId, scheduleTimelineRefresh]); - - useEffect(() => { - if (!active) return; - const interval = window.setInterval(() => { - void refreshTimeline(); - }, 12_000); - return () => window.clearInterval(interval); - }, [active, refreshTimeline]); - - useEffect(() => { - return () => { - if (refreshTimerRef.current !== null) { - window.clearTimeout(refreshTimerRef.current); - } - }; - }, []); - - // Auto-scroll to top (newest first) - useEffect(() => { - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [events, autoScroll]); - - const handleScroll = useCallback(() => { - if (!scrollRef.current) return; - const { scrollTop } = scrollRef.current; - setAutoScroll(scrollTop <= 10); - }, []); - - const filteredEvents = useMemo(() => { - let result = events; - - if (!showRawEvents) { - result = result.filter((ev) => !HIDDEN_MAINTENANCE_EVENT_TYPES.has(ev.eventType)); - } - - // Category filter - if (category !== "All Events") { - result = result.filter((ev) => { - const config = EVENT_CONFIG[ev.eventType] ?? DEFAULT_CONFIG; - return config.category === category; - }); - } - - // Severity filter - if (severity === "warnings") { - result = result.filter((ev) => { - const s = eventSeverity(ev); - return s === "warning" || s === "error"; - }); - } else if (severity === "errors") { - result = result.filter((ev) => eventSeverity(ev) === "error"); - } - - // Text search - if (searchText.trim()) { - const lower = searchText.toLowerCase(); - result = result.filter((ev) => - ev.reason.toLowerCase().includes(lower) || - ev.eventType.toLowerCase().includes(lower) || - (ev.stepId && ev.stepId.toLowerCase().includes(lower)) - ); - } - - return result; - }, [events, category, severity, searchText, showRawEvents]); - - const grouped = useMemo(() => collapseTimelineEvents(filteredEvents), [filteredEvents]); - - return ( - <div className="flex flex-col gap-2"> - {/* Compact filter controls */} - <div className="flex items-center gap-2 flex-wrap"> - <select - value={category} - onChange={(e) => setCategory(e.target.value)} - className="h-7 px-2 text-[10px] outline-none" - style={{ background: "#0C0A10", border: "1px solid #27272A", color: "#FAFAFA", fontFamily: "var(--font-sans)", fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "1px", borderRadius: 0 }} - > - {CATEGORY_OPTIONS.map((opt) => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - - <input - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - placeholder="Filter events..." - className="h-7 w-40 px-2 text-[11px] outline-none" - style={{ background: "#0C0A10", border: "1px solid #27272A", color: "#FAFAFA", fontFamily: "var(--font-sans)", borderRadius: 0 }} - /> - - <div className="flex overflow-hidden" style={{ border: "1px solid #1E1B26", borderRadius: 0 }}> - {(["all", "warnings", "errors"] as const).map((sev) => ( - <button - key={sev} - onClick={() => setSeverity(sev)} - className="px-2 py-0.5 text-[11px] font-medium transition-colors" - style={severity === sev - ? { background: "#A78BFA18", color: "#A78BFA", fontFamily: "var(--font-sans)" } - : { background: "#13101A", color: "#71717A", fontFamily: "var(--font-sans)" } - } - > - {sev === "all" ? "All" : sev === "warnings" ? "Warnings" : "Errors"} - </button> - ))} - </div> - - {(category !== "All Events" || severity !== "all" || searchText) && ( - <button - onClick={() => { setCategory("All Events"); setSeverity("all"); setSearchText(""); }} - className="text-[11px] transition-colors hover:text-white" - style={{ color: "#71717A", fontFamily: "var(--font-sans)" }} - > - Clear - </button> - )} - - <button - type="button" - onClick={() => setShowRawEvents((prev) => !prev)} - className="px-2 py-0.5 text-[10px] font-medium transition-colors" - style={showRawEvents - ? { background: "#A78BFA18", border: "1px solid #A78BFA30", color: "#A78BFA", fontFamily: "var(--font-sans)" } - : { background: "#13101A", border: "1px solid #1E1B26", color: "#71717A", fontFamily: "var(--font-sans)" } - } - title="Show raw maintenance events (scheduler ticks, cap adjustments, heartbeats)." - > - {showRawEvents ? "RAW: ON" : "RAW: OFF"} - </button> - </div> - - {/* Event list */} - <div - ref={scrollRef} - onScroll={handleScroll} - className="relative max-h-[400px] overflow-y-auto" - style={{ border: "1px solid #1E1B26", background: "#13101A", borderRadius: 0 }} - > - {!autoScroll && ( - <button - onClick={() => { - setAutoScroll(true); - if (scrollRef.current) scrollRef.current.scrollTop = 0; - }} - className="sticky top-0 z-10 w-full px-2 py-1 text-[10px] text-center transition-colors" - style={{ background: "#A78BFA", color: "#0F0D14", fontFamily: "var(--font-sans)", fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "1px", borderRadius: 0 }} - > - Jump to latest - </button> - )} - - {grouped.length === 0 ? ( - <div className="px-3 py-6 text-center text-xs" style={{ color: "#71717A", fontFamily: "var(--font-sans)" }}> - {events.length === 0 ? "No events yet" : "No events match the active filters"} - </div> - ) : ( - <div style={{ borderColor: "#1E1B26" }} className="divide-y"> - {grouped.map((group) => { - const representative = group.item; - const config = EVENT_CONFIG[representative.eventType] ?? DEFAULT_CONFIG; - const isCollapsed = group.count > 1; - - if (isCollapsed && expandedId !== representative.id) { - // Show collapsed group with count badge (VAL-UX-002) - return ( - <button - key={representative.id} - onClick={() => setExpandedId(representative.id)} - className="w-full text-left px-3 py-1.5 transition-colors" - style={{ borderColor: "#1E1B26" }} - onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "#1A1720"; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} - > - <div className="flex items-center gap-2"> - <span className={cn("text-[12px] leading-none", config.color)}> - {config.icon} - </span> - <span - className="px-1.5 py-0.5 text-[10px]" - style={{ background: "#1E1B26", color: "#71717A", fontFamily: "var(--font-sans)", fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "1px", borderRadius: 0 }} - > - {config.label} - </span> - {/* Count badge (VAL-UX-002) */} - <span - className="px-1.5 py-0.5 text-[10px] font-bold" - style={{ background: "#A78BFA18", color: "#A78BFA", border: "1px solid #A78BFA30", fontFamily: "var(--font-sans)", fontSize: "10px", letterSpacing: "0.5px", borderRadius: 0 }} - > - x{group.count} - </span> - <span className="text-xs truncate" style={{ color: "#71717A", fontFamily: "var(--font-sans)" }}> - {representative.reason} - </span> - <span - className="ml-auto shrink-0 text-[11px]" - title={formatTimestamp(representative.createdAt)} - style={{ color: "#52525B", fontFamily: "var(--font-sans)", fontSize: "10px" }} - > - {relativeTime(representative.createdAt)} - </span> - </div> - </button> - ); - } - - // Expanded or single-event: render all events in group - const eventsToRender = isCollapsed ? group.collapsed : [representative]; - return eventsToRender.map((ev) => { - const evConfig = EVENT_CONFIG[ev.eventType] ?? DEFAULT_CONFIG; - const isExpanded = expandedId === ev.id; - const sev = eventSeverity(ev); - - const rowStyle: React.CSSProperties = { borderColor: "#1E1B26" }; - if (sev === "error") { - rowStyle.borderLeft = "2px solid #EF4444"; - rowStyle.background = "#EF444408"; - } else if (sev === "warning") { - rowStyle.borderLeft = "2px solid #F59E0B"; - rowStyle.background = "#F59E0B08"; - } - - return ( - <button - key={ev.id} - onClick={() => setExpandedId(isExpanded ? null : ev.id)} - className="w-full text-left px-3 py-2 transition-colors" - style={rowStyle} - onMouseEnter={(e) => { - if (sev === "info") (e.currentTarget as HTMLElement).style.background = "#1A1720"; - }} - onMouseLeave={(e) => { - if (sev === "error") (e.currentTarget as HTMLElement).style.background = "#EF444408"; - else if (sev === "warning") (e.currentTarget as HTMLElement).style.background = "#F59E0B08"; - else (e.currentTarget as HTMLElement).style.background = "transparent"; - }} - > - <div className="flex items-start gap-2"> - <span className={cn("mt-0.5 text-[12px] leading-none", evConfig.color)}> - {evConfig.icon} - </span> - <div className="min-w-0 flex-1"> - <div className="flex items-center gap-2"> - <span - className="px-1.5 py-0.5 text-[10px]" - style={{ background: "#1E1B26", color: "#71717A", fontFamily: "var(--font-sans)", fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "1px", borderRadius: 0 }} - > - {evConfig.label} - </span> - {/* Error source badge (VAL-UX-008) */} - {sev !== "info" && ev.reason && (() => { - const source = classifyErrorSource(ev.reason); - const srcColor = ERROR_SOURCE_COLORS[source]; - return ( - <span - className="px-1 py-0.5 text-[10px]" - style={{ - background: `${srcColor}18`, - color: srcColor, - border: `1px solid ${srcColor}30`, - fontFamily: "var(--font-sans)", - fontSize: "10px", - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "0.5px", - }} - > - {source} - </span> - ); - })()} - <span className="flex-1 truncate text-xs" style={{ color: "#FAFAFA", wordBreak: "break-word" }}> - {ev.reason} - </span> - <span - className="shrink-0" - title={formatTimestamp(ev.createdAt)} - style={{ color: "#52525B", fontFamily: "var(--font-sans)", fontSize: "10px" }} - > - {relativeTime(ev.createdAt)} - </span> - </div> - {ev.stepId && ( - <div className="mt-0.5 truncate" style={{ color: "#52525B", fontFamily: "var(--font-sans)", fontSize: "11px" }}> - step: {ev.stepId.slice(0, 8)} - {ev.attemptId ? ` / attempt: ${ev.attemptId.slice(0, 8)}` : ""} - </div> - )} - {isExpanded && ev.detail && ( - <pre - className="mt-1.5 max-h-[200px] overflow-auto p-2 whitespace-pre-wrap break-all" - style={{ background: "#0C0A10", fontFamily: "var(--font-sans)", color: "#71717A", fontSize: "10px", borderRadius: 0 }} - > - {JSON.stringify(ev.detail, null, 2)} - </pre> - )} - </div> - </div> - </button> - ); - }); - })} - </div> - )} - </div> - - <div className="text-right" style={{ color: "#52525B", fontFamily: "var(--font-sans)", fontSize: "10px", textTransform: "uppercase", letterSpacing: "1px" }}> - {filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""} - {filteredEvents.length !== events.length ? ` (filtered from ${events.length})` : ""} - </div> - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/OrchestratorDAG.tsx b/apps/desktop/src/renderer/components/missions/OrchestratorDAG.tsx deleted file mode 100644 index 6d6739741..000000000 --- a/apps/desktop/src/renderer/components/missions/OrchestratorDAG.tsx +++ /dev/null @@ -1,932 +0,0 @@ -import React, { useMemo, useState, useRef, useCallback, useEffect } from "react"; -import dagre from "dagre"; -import type { OrchestratorStep, OrchestratorAttempt, OrchestratorClaim, DagMutationEvent } from "../../../shared/types"; -import { cn } from "../ui/cn"; -import { relativeWhen, formatTime } from "../../lib/format"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; - -type Props = { - steps: OrchestratorStep[]; - attempts: OrchestratorAttempt[]; - claims?: OrchestratorClaim[]; - selectedStepId?: string | null; - onStepClick?: (stepId: string) => void; - runId?: string; -}; - -const STATUS_COLORS: Record<string, string> = { - pending: "#6b7280", - ready: "#3b82f6", - running: "#8b5cf6", - succeeded: "#22c55e", - failed: "#ef4444", - blocked: "#f59e0b", - skipped: "#9ca3af", - superseded: "#f59e0b", - canceled: "#6b7280", -}; - -const PHASE_TINT: Record<string, string> = { - analysis: "rgba(59, 130, 246, 0.06)", - code: "rgba(139, 92, 246, 0.06)", - implementation: "rgba(139, 92, 246, 0.06)", - test: "rgba(6, 182, 212, 0.06)", - validation: "rgba(6, 182, 212, 0.06)", - milestone: "rgba(167, 139, 250, 0.14)", - review: "rgba(245, 158, 11, 0.06)", - integration: "rgba(16, 185, 129, 0.06)", - merge: "rgba(236, 72, 153, 0.06)", - command: "rgba(168, 85, 247, 0.06)", -}; - -const MERGE_NODE_KINDS = new Set(["merge", "integration"]); - -function getPhaseKind(step: OrchestratorStep): string { - const stepType = typeof step.metadata?.stepType === "string" ? step.metadata.stepType : ""; - const taskType = typeof step.metadata?.taskType === "string" ? step.metadata.taskType : ""; - return stepType || taskType || ""; -} - -function asRecord(value: unknown): Record<string, unknown> | null { - return value && typeof value === "object" ? (value as Record<string, unknown>) : null; -} - -function resolveMilestoneValidationCriteria(step: OrchestratorStep): string | null { - const meta = asRecord(step.metadata); - if (!meta) return null; - - const doneCriteria = typeof meta.doneCriteria === "string" ? meta.doneCriteria.trim() : ""; - if (doneCriteria.length > 0) return doneCriteria; - - const contract = asRecord(meta.validationContract); - const contractCriteria = typeof contract?.criteria === "string" ? contract.criteria.trim() : ""; - if (contractCriteria.length > 0) return contractCriteria; - - const planStep = asRecord(meta.planStep); - const outputContract = asRecord(planStep?.outputContract); - const completionCriteria = typeof outputContract?.completionCriteria === "string" - ? outputContract.completionCriteria.trim() - : ""; - if (completionCriteria.length > 0) return completionCriteria; - - return null; -} - -const BASE_NODE_W = 160; -const NODE_H = 60; -const GAP_Y = 20; -const GAP_X = 100; -const PADDING = 20; - -type LayoutNode = { - step: OrchestratorStep; - col: number; - row: number; - x: number; - y: number; - attemptCount: number; - nodeW: number; -}; - -type NodeAnimState = "entering" | "exiting" | "splitting" | "normal"; - -/** A tracked DAG mutation for the mini-timeline. */ -type MutationEntry = { - id: string; - mutation: DagMutationEvent["mutation"]; - timestamp: string; - source: DagMutationEvent["source"]; -}; - -const MUTATION_ICONS: Record<string, string> = { - step_added: "+", - step_skipped: "~", - steps_merged: "M", - step_split: "/", - dependency_changed: "->", - status_changed: "*", -}; - -function computeLayout(steps: OrchestratorStep[], attemptsByStep: Map<string, number>, maxDepth: { value: number }): LayoutNode[] { - if (steps.length === 0) return []; - - const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir: "LR", nodesep: GAP_Y, ranksep: GAP_X, marginx: PADDING, marginy: PADDING }); - g.setDefaultEdgeLabel(() => ({})); - - const stepMap = new Map<string, OrchestratorStep>(); - for (const step of steps) { - stepMap.set(step.id, step); - g.setNode(step.id, { width: BASE_NODE_W, height: NODE_H }); - } - - for (const step of steps) { - for (const depId of step.dependencyStepIds) { - if (stepMap.has(depId)) { - g.setEdge(depId, step.id); - } - } - } - - dagre.layout(g); - - const nodes: LayoutNode[] = []; - let mDepth = 0; - - for (const step of steps) { - const node = g.node(step.id); - if (!node) continue; - - const x = node.x - node.width / 2; - const y = node.y - node.height / 2; - const col = Math.round(x / (BASE_NODE_W + GAP_X)); - if (col > mDepth) mDepth = col; - - nodes.push({ - step, - col, - row: 0, - x, - y, - attemptCount: attemptsByStep.get(step.id) ?? 0, - nodeW: BASE_NODE_W, - }); - } - - maxDepth.value = mDepth; - return nodes; -} - -function truncateTitle(title: string, maxLen = 18): string { - return title.length > maxLen ? title.slice(0, maxLen - 1) + "\u2026" : title; -} - -/** Produce a short human description for a mutation. */ -function describeMutation(m: DagMutationEvent["mutation"]): string { - switch (m.type) { - case "step_added": - return `Added step '${m.step.title}'`; - case "step_skipped": - return `Skipped '${m.stepKey}' (${m.reason})`; - case "steps_merged": - return `Merged ${m.sourceKeys.join(", ")} -> '${m.targetStep.title}'`; - case "step_split": - return `Split '${m.sourceKey}' into ${m.children.length} steps`; - case "dependency_changed": - return `Deps changed for '${m.stepKey}'`; - case "status_changed": - return `'${m.stepKey}' -> ${m.newStatus}`; - } -} - -/** Resolve the most recent heartbeat for a step from its claims. */ -function resolveHeartbeatForStep(stepId: string, stepAttemptIds: Set<string>, claims: OrchestratorClaim[]): string | null { - const relevant = claims.filter((c) => c.stepId === stepId || (c.attemptId ? stepAttemptIds.has(c.attemptId) : false)); - if (!relevant.length) return null; - return [...relevant].sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))[0]?.heartbeatAt ?? null; -} - -type TooltipInfo = { - title: string; - status: string; - heartbeat: string; - stepKey: string; - validationCriteria: string | null; - x: number; - y: number; -}; - -const MAX_TIMELINE_ENTRIES = 8; - -export const OrchestratorDAG = React.memo(function OrchestratorDAG({ steps, attempts, claims, selectedStepId, onStepClick, runId }: Props) { - const [hoveredId, setHoveredId] = useState<string | null>(null); - const [tooltip, setTooltip] = useState<TooltipInfo | null>(null); - const containerRef = useRef<HTMLDivElement>(null); - - // --- Reactive mutation state --- - const [mutations, setMutations] = useState<MutationEntry[]>([]); - const [nodeAnimStates, setNodeAnimStates] = useState<Map<string, NodeAnimState>>(new Map()); - const [flashedEdges, setFlashedEdges] = useState<Set<string>>(new Set()); - - const mutationCount = mutations.length; - - // Clear animation states after their durations expire. - const clearAnimTimer = useRef<ReturnType<typeof setTimeout> | null>(null); - - const handleMutation = useCallback((event: DagMutationEvent) => { - const m = event.mutation; - setNodeAnimStates((prev) => { - const next = new Map(prev); - switch (m.type) { - case "step_added": - next.set(m.step.id, "entering"); - break; - case "step_skipped": { - // Find by stepKey since we may not have the step id directly - const found = steps.find((s) => s.stepKey === m.stepKey); - if (found) next.set(found.id, "exiting"); - break; - } - case "steps_merged": { - // The target step enters, source steps exit - next.set(m.targetStep.id, "entering"); - for (const srcKey of m.sourceKeys) { - const found = steps.find((s) => s.stepKey === srcKey); - if (found && found.id !== m.targetStep.id) next.set(found.id, "exiting"); - } - break; - } - case "step_split": { - for (const child of m.children) { - next.set(child.id, "splitting"); - } - break; - } - case "dependency_changed": { - const found = steps.find((s) => s.stepKey === m.stepKey); - if (found) { - setFlashedEdges((prevE) => { - const nextE = new Set(prevE); - for (const dep of m.newDeps) { - nextE.add(`${dep}->${m.stepKey}`); - } - return nextE; - }); - } - break; - } - } - return next; - }); - - // Auto-clear animations after 2s - if (clearAnimTimer.current) clearTimeout(clearAnimTimer.current); - clearAnimTimer.current = setTimeout(() => { - setNodeAnimStates(new Map()); - setFlashedEdges(new Set()); - }, 2000); - }, [steps]); - - // Subscribe to DagMutationEvent from IPC - useEffect(() => { - if (!runId) return; - const cleanup = window.ade.orchestrator.onDagMutation((event) => { - if (event.runId !== runId) return; - setMutations((prev) => { - const next = [...prev, { - id: `${event.timestamp}-${event.mutation.type}`, - mutation: event.mutation, - timestamp: event.timestamp, - source: event.source, - }]; - // Cap at MAX_TIMELINE_ENTRIES to prevent unbounded memory growth - return next.length > MAX_TIMELINE_ENTRIES ? next.slice(-MAX_TIMELINE_ENTRIES) : next; - }); - handleMutation(event); - }); - return cleanup; - }, [runId, handleMutation]); - - // Clean up timer on unmount - useEffect(() => { - return () => { - if (clearAnimTimer.current) clearTimeout(clearAnimTimer.current); - }; - }, []); - - const attemptsByStep = useMemo(() => { - const map = new Map<string, number>(); - for (const attempt of attempts) { - map.set(attempt.stepId, (map.get(attempt.stepId) ?? 0) + 1); - } - return map; - }, [attempts]); - - /** Map of stepId -> Set of attemptIds for that step */ - const attemptIdsByStep = useMemo(() => { - const map = new Map<string, Set<string>>(); - for (const attempt of attempts) { - if (!map.has(attempt.stepId)) map.set(attempt.stepId, new Set()); - map.get(attempt.stepId)!.add(attempt.id); - } - return map; - }, [attempts]); - - const handleNodeHover = useCallback((stepId: string | null, event?: React.MouseEvent) => { - setHoveredId(stepId); - if (!stepId || !event || !containerRef.current) { - setTooltip(null); - return; - } - const step = steps.find((s) => s.id === stepId); - if (!step) { setTooltip(null); return; } - - const rect = containerRef.current.getBoundingClientRect(); - const hb = resolveHeartbeatForStep(stepId, attemptIdsByStep.get(stepId) ?? new Set(), claims ?? []); - const phaseKind = getPhaseKind(step).toLowerCase(); - const validationCriteria = phaseKind === "milestone" ? resolveMilestoneValidationCriteria(step) : null; - - setTooltip({ - title: step.title, - status: step.status, - heartbeat: hb ? relativeWhen(hb) : "--", - stepKey: step.stepKey, - validationCriteria, - x: event.clientX - rect.left + 12, - y: event.clientY - rect.top - 8, - }); - }, [steps, claims, attemptIdsByStep]); - - const maxDepthRef = useMemo(() => ({ value: 0 }), []); - const nodes = useMemo(() => computeLayout(steps, attemptsByStep, maxDepthRef), [steps, attemptsByStep, maxDepthRef]); - - const nodeMap = useMemo(() => { - const map = new Map<string, LayoutNode>(); - for (const node of nodes) map.set(node.step.id, node); - return map; - }, [nodes]); - - // Compute SVG dimensions - const svgWidth = useMemo(() => { - if (nodes.length === 0) return 300; - return Math.max(...nodes.map((n) => n.x + n.nodeW)) + PADDING; - }, [nodes]); - - const svgHeight = useMemo(() => { - if (nodes.length === 0) return 100; - return Math.max(...nodes.map((n) => n.y + NODE_H)) + PADDING; - }, [nodes]); - - // Compute edges - const edges = useMemo(() => { - const result: Array<{ - fromId: string; - toId: string; - x1: number; - y1: number; - x2: number; - y2: number; - satisfied: boolean; - }> = []; - - for (const node of nodes) { - for (const depId of node.step.dependencyStepIds) { - const depNode = nodeMap.get(depId); - if (!depNode) continue; - const fromStep = depNode.step; - const satisfied = fromStep.status === "succeeded"; - result.push({ - fromId: depId, - toId: node.step.id, - x1: depNode.x + depNode.nodeW, - y1: depNode.y + NODE_H / 2, - x2: node.x, - y2: node.y + NODE_H / 2, - satisfied, - }); - } - } - return result; - }, [nodes, nodeMap]); - - // Build a set of edges that should flash (by looking up step keys of involved edges) - const stepKeyToId = useMemo(() => { - const map = new Map<string, string>(); - for (const s of steps) map.set(s.stepKey, s.id); - return map; - }, [steps]); - - const flashedEdgeIds = useMemo(() => { - const set = new Set<string>(); - for (const key of flashedEdges) { - const [from, to] = key.split("->"); - const fromId = stepKeyToId.get(from ?? ""); - const toId = stepKeyToId.get(to ?? ""); - if (fromId && toId) set.add(`${fromId}-${toId}`); - } - return set; - }, [flashedEdges, stepKeyToId]); - - // Recent mutations for the mini-timeline (last N) - const recentMutations = useMemo(() => { - return mutations.slice(-MAX_TIMELINE_ENTRIES); - }, [mutations]); - - if (steps.length === 0) { - return ( - <div - className="flex items-center justify-center p-6" - style={{ border: `1px solid ${COLORS.border}`, background: COLORS.cardBg, color: COLORS.textMuted, fontFamily: MONO_FONT, fontSize: "11px", textTransform: "uppercase", letterSpacing: "1px" }} - > - No steps to display - </div> - ); - } - - return ( - <div style={{ border: `1px solid ${COLORS.border}`, background: COLORS.cardBg }}> - {/* Header with mutation badge */} - {mutationCount > 0 && ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: 8, - padding: "4px 10px", - borderBottom: `1px solid ${COLORS.border}`, - fontFamily: MONO_FONT, - fontSize: 10, - }} - > - <span - style={{ - display: "inline-flex", - alignItems: "center", - gap: 4, - padding: "1px 8px", - background: "rgba(168, 85, 247, 0.15)", - border: "1px solid rgba(168, 85, 247, 0.3)", - color: "#C4B5FD", - fontSize: 10, - fontWeight: 600, - letterSpacing: "0.5px", - }} - > - Plan revised {mutationCount}x - </span> - <span style={{ color: COLORS.textDim }}> - Last: {recentMutations.length > 0 ? describeMutation(recentMutations[recentMutations.length - 1]!.mutation) : "--"} - </span> - </div> - )} - - {/* DAG SVG */} - <div - ref={containerRef} - className="overflow-auto relative" - style={{ perspective: '1200px', perspectiveOrigin: '50% 40%' }} - > - {/* Inline style for flow animation */} - <style>{` - @keyframes ade-flow-dash { - to { stroke-dashoffset: -14; } - } - `}</style> - <svg - style={{ transformStyle: 'preserve-3d' }} - width={svgWidth} - height={svgHeight} - viewBox={`0 0 ${svgWidth} ${svgHeight}`} - className="block" - > - {/* Edge lines with animated flow */} - {edges.map((edge) => { - const midX = (edge.x1 + edge.x2) / 2; - const edgeKey = `${edge.fromId}-${edge.toId}`; - const isFlashed = flashedEdgeIds.has(edgeKey); - return ( - <g key={edgeKey} className={isFlashed ? "ade-dag-edge-changed" : undefined}> - {/* Base edge */} - <path - d={`M${edge.x1},${edge.y1} C${midX},${edge.y1} ${midX},${edge.y2} ${edge.x2},${edge.y2}`} - fill="none" - stroke={isFlashed ? "#a78bfa" : edge.satisfied ? "#22c55e" : "#6b7280"} - strokeWidth={isFlashed ? 2.5 : 1.5} - opacity={isFlashed ? 0.8 : 0.4} - /> - {/* Animated dash overlay for unsatisfied edges */} - {!edge.satisfied && ( - <path - d={`M${edge.x1},${edge.y1} C${midX},${edge.y1} ${midX},${edge.y2} ${edge.x2},${edge.y2}`} - fill="none" - stroke="#6b7280" - strokeWidth={1.5} - strokeDasharray="4 4" - opacity={0.6} - style={{ animation: 'ade-flow-dash 1.5s linear infinite' }} - /> - )} - {/* Animated flow for satisfied edges */} - {edge.satisfied && ( - <path - d={`M${edge.x1},${edge.y1} C${midX},${edge.y1} ${midX},${edge.y2} ${edge.x2},${edge.y2}`} - fill="none" - stroke="#22c55e" - strokeWidth={1.5} - strokeDasharray="6 8" - opacity={0.7} - style={{ animation: 'ade-flow-dash 1.5s linear infinite' }} - /> - )} - </g> - ); - })} - - {/* Step nodes */} - {nodes.map((node) => { - const statusColor = STATUS_COLORS[node.step.status] ?? "#6b7280"; - const isRunning = node.step.status === "running"; - const isHovered = hoveredId === node.step.id; - const isSelected = selectedStepId === node.step.id; - const phaseKind = getPhaseKind(node.step).toLowerCase(); - const phaseTint = PHASE_TINT[phaseKind] ?? "transparent"; - const isMergeNode = MERGE_NODE_KINDS.has(phaseKind); - const isMilestoneNode = phaseKind === "milestone"; - const isGateNode = phaseKind === "review" || phaseKind === "validation"; - const isSkipped = node.step.status === "skipped"; - const isSuperseded = node.step.status === "superseded"; - - const isFailed = node.step.status === "failed"; - const isSucceeded = node.step.status === "succeeded"; - const nodeW = node.nodeW; - const milestoneAccent = "var(--color-accent, #A78BFA)"; - const diamondPoints = `${nodeW / 2},0 ${nodeW},${NODE_H / 2} ${nodeW / 2},${NODE_H} 0,${NODE_H / 2}`; - const diamondInnerPoints = `${nodeW / 2},4 ${nodeW - 4},${NODE_H / 2} ${nodeW / 2},${NODE_H - 4} 4,${NODE_H / 2}`; - - // Animation state from mutations - const animState = nodeAnimStates.get(node.step.id) ?? "normal"; - const animClass = - animState === "entering" ? "ade-dag-entering" : - animState === "exiting" ? "ade-dag-exiting" : - animState === "splitting" ? "ade-dag-splitting" : - undefined; - - return ( - <g - key={node.step.id} - transform={`translate(${node.x}, ${node.y})`} - onClick={() => onStepClick?.(node.step.id)} - onMouseEnter={(e) => handleNodeHover(node.step.id, e)} - onMouseMove={(e) => { if (hoveredId === node.step.id && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setTooltip((prev) => prev ? { ...prev, x: e.clientX - rect.left + 12, y: e.clientY - rect.top - 8 } : prev); } }} - onMouseLeave={() => handleNodeHover(null)} - className={cn("cursor-pointer", isFailed && "ade-node-failed-pulse", animClass)} - > - {/* Selected node highlight ring */} - {isSelected && !isRunning && ( - isMilestoneNode ? ( - <polygon - points={`${nodeW / 2},-3 ${nodeW + 3},${NODE_H / 2} ${nodeW / 2},${NODE_H + 3} -3,${NODE_H / 2}`} - fill="none" - stroke="#A78BFA" - strokeWidth={2} - opacity={0.6} - /> - ) : ( - <rect - x={-3} - y={-3} - width={nodeW + 6} - height={NODE_H + 6} - rx={0} - fill="none" - stroke="#A78BFA" - strokeWidth={2} - opacity={0.6} - /> - ) - )} - <g - style={{ - transformStyle: 'preserve-3d', - transition: 'transform 200ms ease', - transformOrigin: `${nodeW / 2}px ${NODE_H / 2}px`, - transform: isHovered ? 'translateZ(10px) scale(1.05)' : 'scale(1)' - }} - > - {/* Running: subtle live outline */} - {isRunning && ( - isMilestoneNode ? ( - <polygon - points={`${nodeW / 2},-3 ${nodeW + 3},${NODE_H / 2} ${nodeW / 2},${NODE_H + 3} -3,${NODE_H / 2}`} - fill="none" - stroke={statusColor} - strokeWidth={1.5} - strokeDasharray="8 6" - opacity={0.7} - > - <animate attributeName="opacity" values="0.75;0.25;0.75" dur="1.6s" repeatCount="indefinite" /> - </polygon> - ) : ( - <rect x={-3} y={-3} width={nodeW + 6} height={NODE_H + 6} rx={0} - fill="none" stroke={statusColor} strokeWidth={1.5} - strokeDasharray="8 6" opacity={0.7}> - <animate attributeName="opacity" values="0.75;0.25;0.75" dur="1.6s" repeatCount="indefinite" /> - </rect> - ) - )} - - {/* Running glow pulse */} - {isRunning && ( - <rect - x={-2} - y={-2} - width={nodeW + 4} - height={NODE_H + 4} - rx={0} - fill="none" - stroke={statusColor} - strokeWidth={2} - opacity={0.4} - > - <animate - attributeName="opacity" - values="0.4;0.1;0.4" - dur="1.5s" - repeatCount="indefinite" - /> - </rect> - )} - - {/* Phase tint background */} - {isMilestoneNode ? ( - <polygon - points={diamondPoints} - fill={phaseTint} - /> - ) : ( - <rect - width={nodeW} - height={NODE_H} - rx={0} - fill={phaseTint} - /> - )} - - {/* Node background -- diamond shape for gates, regular for others */} - {isMilestoneNode ? ( - <> - <polygon - points={diamondPoints} - fill={isHovered ? COLORS.hoverBg : COLORS.cardBg} - stroke={statusColor} - strokeWidth={isHovered ? 2 : 1.5} - opacity={0.95} - /> - <polygon - points={diamondInnerPoints} - fill="none" - stroke={milestoneAccent} - strokeWidth={1} - strokeDasharray="3 2" - opacity={0.9} - /> - </> - ) : isGateNode ? ( - <rect - x={4} - y={4} - width={nodeW - 8} - height={NODE_H - 8} - rx={0} - fill={isHovered ? COLORS.hoverBg : COLORS.cardBg} - stroke={statusColor} - strokeWidth={isHovered ? 2 : 1.5} - strokeDasharray="4 2" - opacity={0.9} - /> - ) : isMergeNode ? ( - <rect - width={nodeW} - height={NODE_H} - rx={0} - fill={isHovered ? COLORS.hoverBg : COLORS.cardBg} - stroke={statusColor} - strokeWidth={isHovered ? 2 : 1.5} - opacity={0.9} - /> - ) : ( - <rect - width={nodeW} - height={NODE_H} - rx={0} - fill={isHovered ? COLORS.hoverBg : COLORS.cardBg} - stroke={statusColor} - strokeWidth={isHovered ? 2 : 1.5} - opacity={0.9} - /> - )} - - {/* Status indicator bar at top */} - {isMilestoneNode ? ( - <rect - x={10} - y={NODE_H / 2 - 1.5} - width={nodeW - 20} - height={3} - rx={0} - fill={statusColor} - /> - ) : ( - <rect - x={8} - y={0} - width={nodeW - 16} - height={3} - rx={0} - fill={statusColor} - /> - )} - - {/* Title text -- strikethrough for skipped/superseded steps */} - <text - x={nodeW / 2} - y={26} - textAnchor="middle" - fill={isSkipped || isSuperseded ? COLORS.textMuted : COLORS.textPrimary} - fontSize={11} - fontWeight={500} - fontFamily="var(--font-sans)" - textDecoration={isSkipped || isSuperseded ? "line-through" : undefined} - > - {truncateTitle(node.step.title)} - </text> - - {/* Status text */} - <text - x={nodeW / 2} - y={42} - textAnchor="middle" - fill={statusColor} - fontSize={9} - fontWeight={400} - fontFamily="var(--font-sans)" - > - {node.step.status} - </text> - - {/* Completed: animated checkmark SVG */} - {isSucceeded && ( - <g transform={`translate(${nodeW - 20}, 2)`}> - <circle cx={8} cy={8} r={8} fill="#22c55e" opacity={0.2} /> - <path - d="M5 8.5 L7.5 11 L11.5 6" - fill="none" - stroke="#22c55e" - strokeWidth={2} - strokeLinecap="round" - strokeLinejoin="round" - strokeDasharray="12" - strokeDashoffset="12" - > - <animate - attributeName="stroke-dashoffset" - from="12" - to="0" - dur="0.5s" - fill="freeze" - /> - </path> - </g> - )} - - {/* Failed: red X icon with pulse */} - {isFailed && ( - <g transform={`translate(${nodeW - 20}, 2)`}> - <circle cx={8} cy={8} r={8} fill="#ef4444" opacity={0.2}> - <animate - attributeName="opacity" - values="0.2;0.5;0.2" - dur="1s" - repeatCount="3" - /> - </circle> - <path - d="M5.5 5.5 L10.5 10.5 M10.5 5.5 L5.5 10.5" - fill="none" - stroke="#ef4444" - strokeWidth={1.5} - strokeLinecap="round" - /> - </g> - )} - - {/* Attempt count badge */} - {node.attemptCount > 0 && ( - <g transform={`translate(${nodeW - 16}, ${NODE_H - 16})`}> - <rect - width={20} - height={14} - rx={0} - fill={statusColor} - opacity={0.2} - /> - <text - x={10} - y={10} - textAnchor="middle" - fill={statusColor} - fontSize={9} - fontWeight={600} - fontFamily="var(--font-sans)" - > - {node.attemptCount} - </text> - </g> - )} - </g> - </g> - ); - })} - </svg> - - {/* Tooltip overlay */} - {tooltip && ( - <div - style={{ - position: "absolute", - left: tooltip.x, - top: tooltip.y, - pointerEvents: "none", - zIndex: 50, - background: COLORS.hoverBg, - border: `1px solid ${COLORS.outlineBorder}`, - padding: "6px 10px", - maxWidth: 260, - fontFamily: MONO_FONT, - }} - > - <div style={{ fontSize: 11, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> - {tooltip.title} - </div> - <div style={{ marginTop: 2, fontSize: 9, color: COLORS.textSecondary, display: "flex", gap: 8 }}> - <span> - <span style={{ color: COLORS.textMuted, textTransform: "uppercase", letterSpacing: "0.5px" }}>Status: </span> - <span style={{ color: STATUS_COLORS[tooltip.status] ?? "#6b7280" }}>{tooltip.status}</span> - </span> - <span> - <span style={{ color: COLORS.textMuted, textTransform: "uppercase", letterSpacing: "0.5px" }}>HB: </span> - <span>{tooltip.heartbeat}</span> - </span> - </div> - <div style={{ marginTop: 1, fontSize: 9, color: COLORS.textDim }}> - {tooltip.stepKey} - </div> - {tooltip.validationCriteria && ( - <div style={{ marginTop: 4, fontSize: 9, color: "#D4D4D8", maxWidth: 240, whiteSpace: "normal", lineHeight: "1.3" }}> - <span style={{ color: "var(--color-accent, #A78BFA)", textTransform: "uppercase", letterSpacing: "0.5px" }}> - Validation: - </span>{" "} - {tooltip.validationCriteria} - </div> - )} - </div> - )} - </div> - - {/* Mini-timeline of plan changes */} - {recentMutations.length > 0 && ( - <div - style={{ - borderTop: `1px solid ${COLORS.border}`, - maxHeight: 140, - overflowY: "auto", - padding: "4px 0", - }} - > - {recentMutations.map((entry) => ( - <div - key={entry.id} - style={{ - display: "flex", - alignItems: "baseline", - gap: 6, - padding: "2px 10px", - fontFamily: MONO_FONT, - fontSize: 10, - lineHeight: "16px", - }} - > - <span style={{ color: COLORS.textDim, flexShrink: 0 }}> - {formatTime(entry.timestamp)} - </span> - <span - style={{ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 16, - height: 14, - fontSize: 9, - fontWeight: 700, - background: "rgba(168, 85, 247, 0.12)", - color: "#C4B5FD", - flexShrink: 0, - }} - > - {MUTATION_ICONS[entry.mutation.type] ?? "?"} - </span> - <span style={{ color: COLORS.textSecondary, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> - {describeMutation(entry.mutation)} - </span> - {entry.source !== "coordinator" && ( - <span style={{ color: COLORS.textDim, flexShrink: 0 }}> - [{entry.source}] - </span> - )} - </div> - ))} - </div> - )} - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/PhaseCardEditor.tsx b/apps/desktop/src/renderer/components/missions/PhaseCardEditor.tsx deleted file mode 100644 index 3d7cb1679..000000000 --- a/apps/desktop/src/renderer/components/missions/PhaseCardEditor.tsx +++ /dev/null @@ -1,559 +0,0 @@ -import React from "react"; -import { useNavigate } from "react-router-dom"; -import { X } from "@phosphor-icons/react"; -import type { PhaseCard, OrchestratorPromptInspector, ValidationEvidenceRequirement } from "../../../shared/types"; -import { COLORS, MONO_FONT, outlineButton } from "../lanes/laneDesignTokens"; -import { ModelSelector } from "./ModelSelector"; -import { PromptInspectorCard } from "./PromptInspectorCard"; - -export type PhaseCardEditorProps = { - phase: PhaseCard; - index: number; - totalCount: number; - expanded: boolean; - readOnly: boolean; - onToggleExpand: () => void; - onUpdate: (updated: PhaseCard) => void; - onMoveUp: () => void; - onMoveDown: () => void; - onRemove?: () => void; - availableModelIds?: string[]; - /** When true, show enable/disable toggle (used in CreateMissionDialog) */ - showToggle?: boolean; - disabled?: boolean; - onToggleDisabled?: () => void; - labelStyle?: React.CSSProperties; - inputStyle?: React.CSSProperties; - planningPromptPreview?: { - missionPrompt: string; - phases: PhaseCard[]; - } | null; -}; - -const DEFAULT_LABEL_STYLE: React.CSSProperties = { - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase" as const, - letterSpacing: "1px", - color: COLORS.textMuted, -}; - -const DEFAULT_INPUT_STYLE: React.CSSProperties = { - height: 28, - width: "100%", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - padding: "0 8px", - fontSize: 11, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - borderRadius: 0, - outline: "none", -}; - -const EVIDENCE_REQUIREMENTS: Array<{ key: ValidationEvidenceRequirement; label: string }> = [ - { key: "planning_document", label: "Plan" }, - { key: "research_summary", label: "Research" }, - { key: "changed_files_summary", label: "Changed files" }, - { key: "test_report", label: "Tests" }, - { key: "review_summary", label: "Review" }, - { key: "risk_notes", label: "Risks" }, - { key: "final_outcome_summary", label: "Outcome" }, - { key: "screenshot", label: "Screenshot" }, - { key: "browser_verification", label: "Browser" }, - { key: "video_recording", label: "Video" }, - { key: "browser_trace", label: "Trace" }, - { key: "console_logs", label: "Console" }, -]; - -export function PhaseCardEditor({ - phase, - index, - totalCount, - expanded, - readOnly, - onToggleExpand, - onUpdate, - onMoveUp, - onMoveDown, - onRemove, - availableModelIds, - showToggle, - disabled: isDisabled, - onToggleDisabled, - labelStyle: lblStyle, - inputStyle: inpStyle, - planningPromptPreview, -}: PhaseCardEditorProps) { - const navigate = useNavigate(); - const openAiProvidersSettings = React.useCallback(() => { - navigate("/settings?tab=ai#ai-providers"); - }, [navigate]); - const labelStyle = lblStyle ?? DEFAULT_LABEL_STYLE; - const inputStyle = inpStyle ?? DEFAULT_INPUT_STYLE; - const textareaStyle: React.CSSProperties = { ...inputStyle, height: "auto", minHeight: 64, lineHeight: "16px" }; - const isPlanningPhase = phase.phaseKey.trim().toLowerCase() === "planning"; - const modelSummary = [ - phase.model.modelId, - phase.model.thinkingLevel ? `${phase.model.thinkingLevel} reasoning` : null, - `Ask Questions ${phase.askQuestions.enabled ? "on" : "off"}`, - ].filter(Boolean).join(" · "); - const [planningInspector, setPlanningInspector] = React.useState<OrchestratorPromptInspector | null>(null); - const [planningInspectorLoading, setPlanningInspectorLoading] = React.useState(false); - const [planningInspectorError, setPlanningInspectorError] = React.useState<string | null>(null); - - const updateField = <K extends keyof PhaseCard>(key: K, value: PhaseCard[K]) => { - onUpdate({ ...phase, [key]: value }); - }; - const updateEvidenceRequirement = (key: ValidationEvidenceRequirement, checked: boolean) => { - const current = new Set(phase.validationGate.evidenceRequirements ?? []); - if (checked) current.add(key); - else current.delete(key); - onUpdate({ - ...phase, - validationGate: { - ...phase.validationGate, - evidenceRequirements: Array.from(current), - }, - }); - }; - - // Stable fingerprint to avoid re-fetching on every object-identity change. - const previewFingerprint = React.useMemo(() => { - if (!isPlanningPhase || !expanded || !planningPromptPreview) return null; - return JSON.stringify({ - missionPrompt: planningPromptPreview.missionPrompt, - phaseInstructions: phase.instructions, - phaseModel: phase.model.modelId, - askQuestionsEnabled: phase.askQuestions.enabled, - phases: planningPromptPreview.phases.map((p) => p.phaseKey), - }); - }, [isPlanningPhase, expanded, planningPromptPreview, phase.instructions, phase.model.modelId, phase.askQuestions.enabled]); - - // Keep a ref to the latest values so the debounced callback always uses fresh data. - const previewArgsRef = React.useRef({ phase, planningPromptPreview }); - previewArgsRef.current = { phase, planningPromptPreview }; - - React.useEffect(() => { - if (!previewFingerprint || !previewArgsRef.current.planningPromptPreview - || !previewArgsRef.current.planningPromptPreview.missionPrompt.trim().length) { - setPlanningInspector(null); - setPlanningInspectorLoading(false); - setPlanningInspectorError(null); - return; - } - let cancelled = false; - setPlanningInspectorLoading(true); - setPlanningInspectorError(null); - const timer = setTimeout(() => { - const args = previewArgsRef.current; - if (!args.planningPromptPreview) return; - void window.ade.orchestrator.getPlanningPromptPreview({ - missionPrompt: args.planningPromptPreview.missionPrompt, - phase: args.phase, - phases: args.planningPromptPreview.phases, - }).then((inspector) => { - if (cancelled) return; - setPlanningInspector(inspector); - setPlanningInspectorLoading(false); - }).catch((error) => { - if (cancelled) return; - setPlanningInspector(null); - setPlanningInspectorError(error instanceof Error ? error.message : String(error)); - setPlanningInspectorLoading(false); - }); - }, 500); - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [previewFingerprint]); - - return ( - <div - className="p-2" - style={{ - background: isDisabled ? "color-mix(in srgb, var(--color-muted) 80%, transparent)" : COLORS.recessedBg, - border: `1px solid ${isDisabled ? COLORS.border + "60" : COLORS.border}`, - opacity: isDisabled ? 0.5 : 1, - transition: "opacity 0.15s ease", - }} - > - <div className="flex items-center gap-2"> - {/* Enable/disable toggle */} - {showToggle && onToggleDisabled && ( - <button - type="button" - onClick={onToggleDisabled} - title={isDisabled ? "Enable phase" : "Disable phase"} - style={{ - width: 28, - height: 14, - background: isDisabled ? COLORS.border : "#22C55E", - border: "none", - borderRadius: 0, - cursor: "pointer", - position: "relative", - flexShrink: 0, - transition: "background 0.2s ease", - }} - > - <div - style={{ - position: "absolute", - top: 2, - left: isDisabled ? 2 : 14, - width: 10, - height: 10, - background: isDisabled ? COLORS.textDim : COLORS.textPrimary, - borderRadius: 0, - transition: "left 0.2s ease", - }} - /> - </button> - )} - - {/* Phase index */} - <span className="text-[10px] font-bold" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {index + 1}. - </span> - - {/* Phase info */} - <div className="min-w-0 flex-1"> - <div className="truncate text-[11px] font-semibold" style={{ color: isDisabled ? COLORS.textDim : COLORS.textPrimary }}> - {phase.name} - {phase.isCustom ? ( - <span style={{ fontSize: 9, fontWeight: 600, color: "#F59E0B", marginLeft: 4, fontFamily: MONO_FONT }}>CUSTOM</span> - ) : null} - {isDisabled ? <span style={{ color: COLORS.textDim, fontWeight: 400 }}> (disabled)</span> : null} - </div> - {phase.description ? ( - <div className="truncate text-[10px]" style={{ color: COLORS.textDim }}> - {phase.description} - </div> - ) : null} - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {modelSummary} - </div> - </div> - - {/* Reorder buttons */} - {!readOnly && ( - <> - <button - type="button" - className="px-1 text-[10px]" - style={{ color: COLORS.textMuted }} - disabled={index === 0} - onClick={onMoveUp} - title="Move up" - > - {"\u2191"} - </button> - <button - type="button" - className="px-1 text-[10px]" - style={{ color: COLORS.textMuted }} - disabled={index === totalCount - 1} - onClick={onMoveDown} - title="Move down" - > - {"\u2193"} - </button> - </> - )} - - {/* Expand/collapse button */} - <button - type="button" - className="px-2 text-[10px] font-bold uppercase tracking-[1px]" - style={outlineButton()} - onClick={onToggleExpand} - disabled={isDisabled && !readOnly} - > - {expanded ? "HIDE" : readOnly ? "VIEW" : "CONFIGURE"} - </button> - - {/* Remove button (custom phases only, not readOnly) */} - {!readOnly && onRemove && phase.isCustom ? ( - <button - type="button" - className="px-1" - style={{ color: COLORS.danger, background: "none", border: "none", cursor: "pointer" }} - onClick={onRemove} - title="Remove custom phase" - > - <X size={12} weight="bold" /> - </button> - ) : null} - </div> - - {/* Expanded detail panel */} - {expanded && !isDisabled ? ( - <div className="mt-2 space-y-2"> - <div className="grid grid-cols-1 gap-2 md:grid-cols-2"> - <label className="space-y-1 text-[10px]"> - <span style={labelStyle}>PHASE NAME</span> - <input - value={phase.name} - onChange={(e) => updateField("name", e.target.value)} - className="h-7 w-full px-2 outline-none" - style={inputStyle} - disabled={readOnly} - /> - </label> - <label className="space-y-1 text-[10px]"> - <span style={labelStyle}>PHASE KEY</span> - <input - value={phase.phaseKey} - onChange={(e) => { - const next = e.target.value - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^_+/, ""); - updateField("phaseKey", next); - }} - className="h-7 w-full px-2 outline-none" - style={inputStyle} - disabled={readOnly || phase.isBuiltIn} - /> - </label> - </div> - - <div className="grid grid-cols-1 gap-2 md:grid-cols-2"> - <div className="space-y-1 text-[10px]"> - <span style={labelStyle}>WORKER MODEL</span> - {readOnly ? ( - <div className="h-7 flex items-center px-2" style={{ ...inputStyle, opacity: 0.7 }}> - {phase.model.modelId} - {phase.model.thinkingLevel ? ` · ${phase.model.thinkingLevel}` : ""} - </div> - ) : ( - <ModelSelector - value={phase.model} - onChange={(config) => updateField("model", config)} - compact - availableModelIds={availableModelIds} - onOpenAiSettings={openAiProvidersSettings} - /> - )} - </div> - <label className="space-y-1 text-[10px]"> - <span style={labelStyle}>VALIDATION TIER</span> - <select - value={phase.validationGate.tier} - onChange={(e) => { - const tier = e.target.value as PhaseCard["validationGate"]["tier"]; - onUpdate({ - ...phase, - validationGate: { - ...phase.validationGate, - tier, - required: tier === "none" ? false : phase.validationGate.required, - }, - }); - }} - className="h-7 w-full px-2 outline-none" - style={inputStyle} - disabled={readOnly} - > - <option value="none">None</option> - <option value="self">Self-check</option> - <option value="dedicated">Dedicated validator</option> - </select> - </label> - </div> - - <label className="space-y-1 text-[10px]"> - <span style={labelStyle}>DESCRIPTION</span> - <input - value={phase.description} - onChange={(e) => updateField("description", e.target.value)} - placeholder="Brief description of what this phase does" - className="h-7 w-full px-2 outline-none" - style={inputStyle} - disabled={readOnly} - /> - </label> - - <label className="space-y-1 text-[10px]"> - <span style={labelStyle}>{isPlanningPhase ? "CUSTOM INSTRUCTIONS" : "INSTRUCTIONS"}</span> - <textarea - value={phase.instructions} - onChange={(e) => updateField("instructions", e.target.value)} - className="w-full px-2 py-1.5 outline-none" - rows={3} - style={textareaStyle} - disabled={readOnly} - /> - </label> - - {/* Require manual approval toggle */} - <div className="flex items-center gap-2"> - <label className="flex items-center gap-1.5 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={phase.requiresApproval === true} - onChange={(e) => { - onUpdate({ ...phase, requiresApproval: e.target.checked }); - }} - disabled={readOnly} - /> - Require manual approval - </label> - </div> - - {isPlanningPhase ? ( - <div className="space-y-1 text-[10px]"> - <span style={labelStyle}>EXACT COMPOSED PLANNER PROMPT</span> - <PromptInspectorCard - inspector={planningInspector} - loading={planningInspectorLoading} - error={planningInspectorError} - title="Read-only system / composed planner prompt" - /> - </div> - ) : null} - - <div className="space-y-1"> - <span style={labelStyle}>ASK QUESTIONS</span> - <div className="flex flex-wrap items-center gap-3"> - <label className="flex items-center gap-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={phase.askQuestions.enabled} - onChange={(e) => { - updateField("askQuestions", { ...phase.askQuestions, enabled: e.target.checked }); - }} - disabled={readOnly} - /> - Enabled - </label> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {(() => { - const name = phase.name || "Active phase"; - if (!phase.askQuestions.enabled) return `${name} will proceed without asking questions.`; - if (phase.askQuestions.maxQuestions == null) return `${name} worker may ask blocking clarification questions without a preset limit.`; - return `${name} worker may ask blocking clarification questions when needed.`; - })()} - </div> - {isPlanningPhase ? ( - <label className="flex items-center gap-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={phase.askQuestions.maxQuestions == null} - onChange={(e) => { - updateField("askQuestions", { - ...phase.askQuestions, - maxQuestions: e.target.checked ? null : 5, - }); - }} - disabled={readOnly || !phase.askQuestions.enabled} - /> - Unlimited planning questions - </label> - ) : null} - <label className="flex items-center gap-1.5 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span style={{ fontSize: 9 }}>Max questions</span> - <input - type="number" - min={1} - max={10} - value={phase.askQuestions.maxQuestions == null ? 5 : Math.max(1, Math.min(10, Number(phase.askQuestions.maxQuestions) || 5))} - onChange={(e) => { - const maxQuestions = Math.max(1, Math.min(10, Number(e.target.value) || 5)); - updateField("askQuestions", { ...phase.askQuestions, maxQuestions }); - }} - className="h-6 w-16 px-1 text-[10px] text-center outline-none" - style={inputStyle} - disabled={readOnly || !phase.askQuestions.enabled || (isPlanningPhase && phase.askQuestions.maxQuestions == null)} - /> - </label> - </div> - </div> - - <div className="space-y-2"> - <span style={labelStyle}>VALIDATION GATE</span> - <div className="flex flex-wrap items-center gap-3"> - <label className="flex items-center gap-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <input - type="checkbox" - checked={phase.validationGate.required} - onChange={(e) => { - onUpdate({ - ...phase, - validationGate: { - ...phase.validationGate, - required: e.target.checked, - tier: e.target.checked && phase.validationGate.tier === "none" ? "self" : phase.validationGate.tier, - }, - }); - }} - disabled={readOnly} - /> - Required before next phase - </label> - <label className="flex items-center gap-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span style={{ fontSize: 9 }}>Missing capability</span> - <select - value={phase.validationGate.capabilityFallback ?? "block"} - onChange={(e) => { - onUpdate({ - ...phase, - validationGate: { - ...phase.validationGate, - capabilityFallback: e.target.value as PhaseCard["validationGate"]["capabilityFallback"], - }, - }); - }} - className="h-6 px-1 text-[10px] outline-none" - style={inputStyle} - disabled={readOnly} - > - <option value="block">Block</option> - <option value="warn">Warn</option> - </select> - </label> - </div> - <textarea - value={phase.validationGate.criteria ?? ""} - onChange={(e) => { - onUpdate({ - ...phase, - validationGate: { - ...phase.validationGate, - criteria: e.target.value, - }, - }); - }} - className="w-full px-2 py-1.5 outline-none" - rows={2} - style={{ ...textareaStyle, minHeight: 48 }} - disabled={readOnly} - placeholder="Pass/fail criteria for this phase" - /> - <div className="flex flex-wrap gap-2"> - {EVIDENCE_REQUIREMENTS.map((entry) => ( - <label - key={entry.key} - className="flex items-center gap-1 text-[10px]" - style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }} - > - <input - type="checkbox" - checked={(phase.validationGate.evidenceRequirements ?? []).includes(entry.key)} - onChange={(e) => updateEvidenceRequirement(entry.key, e.target.checked)} - disabled={readOnly} - /> - {entry.label} - </label> - ))} - </div> - </div> - </div> - ) : null} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/PlanTab.test.ts b/apps/desktop/src/renderer/components/missions/PlanTab.test.ts deleted file mode 100644 index 9bd39c3ae..000000000 --- a/apps/desktop/src/renderer/components/missions/PlanTab.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildExecutableProgressLabel, getVisiblePlanSteps, shouldCompactPlanStepLists } from "./PlanTab"; - -describe("buildExecutableProgressLabel", () => { - it("does not count canceled work as complete", () => { - expect(buildExecutableProgressLabel([{ status: "canceled" }])).toBe( - "0/1 registered executable steps succeeded · 1 canceled", - ); - }); - - it("keeps the compact complete label for succeeded work", () => { - expect(buildExecutableProgressLabel([{ status: "succeeded" }, { status: "succeeded" }])).toBe( - "2/2 registered executable steps complete", - ); - }); - - it("compacts duplicate step lists for large plans until expanded", () => { - const steps = Array.from({ length: 12 }, (_, index) => index + 1); - - expect(shouldCompactPlanStepLists(80)).toBe(false); - expect(shouldCompactPlanStepLists(81)).toBe(true); - expect(getVisiblePlanSteps(steps, true, false, 5)).toEqual([1, 2, 3, 4, 5]); - expect(getVisiblePlanSteps(steps, true, true, 5)).toEqual(steps); - expect(getVisiblePlanSteps(steps, false, false, 5)).toEqual(steps); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/PlanTab.tsx b/apps/desktop/src/renderer/components/missions/PlanTab.tsx deleted file mode 100644 index 48bb5c090..000000000 --- a/apps/desktop/src/renderer/components/missions/PlanTab.tsx +++ /dev/null @@ -1,505 +0,0 @@ -import React, { useMemo, useState } from "react"; -import { SquaresFour } from "@phosphor-icons/react"; -import type { - MissionArtifact, - MissionDetail, - OrchestratorAttempt, - OrchestratorRunGraph, - OrchestratorStep, -} from "../../../shared/types"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; -import { - filterExecutionSteps, - isRecord, - statusGlyph, - STEP_STATUS_HEX, -} from "./missionHelpers"; -import { OrchestratorDAG } from "./OrchestratorDAG"; - -const LARGE_PLAN_STEP_THRESHOLD = 80; -const GROUP_STEP_PREVIEW_LIMIT = 6; -const PHASE_STEP_PREVIEW_LIMIT = 10; - -function toggleSetMember(current: Set<string>, value: string): Set<string> { - const next = new Set(current); - if (next.has(value)) next.delete(value); - else next.add(value); - return next; -} - -type PhaseSection = { - key: string; - name: string; - position: number; - steps: OrchestratorStep[]; -}; - -type PlanGroup = { - key: string; - label: string; - kind: "feature" | "milestone" | "phase"; - steps: OrchestratorStep[]; -}; - -type PlannerReview = { - title: string; - objective: string | null; - domain: string | null; - complexity: string | null; - strategy: string | null; - parallelismCap: number | null; - parallelismRationale: string | null; - assumptions: string[]; - risks: string[]; -}; - -function toText(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function toStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => toText(entry)) - .filter((entry): entry is string => entry != null); -} - -function resolvePhase(step: OrchestratorStep): { key: string; name: string; position: number } { - const meta = isRecord(step.metadata) ? step.metadata : {}; - const key = typeof meta.phaseKey === "string" && meta.phaseKey.trim().length > 0 ? meta.phaseKey.trim() : "development"; - const name = typeof meta.phaseName === "string" && meta.phaseName.trim().length > 0 ? meta.phaseName.trim() : "Development"; - const rawPosition = Number(meta.phasePosition); - const position = Number.isFinite(rawPosition) ? rawPosition : 9999; - return { key, name, position }; -} - -function stepLabel(step: OrchestratorStep): string { - const meta = isRecord(step.metadata) ? step.metadata : {}; - const stepType = typeof meta.stepType === "string" && meta.stepType.trim().length > 0 ? meta.stepType.trim() : "Worker"; - return stepType.replace(/_/g, " "); -} - -function phaseSectionsFromSteps(steps: OrchestratorStep[]): PhaseSection[] { - const sections = new Map<string, PhaseSection>(); - for (const step of [...steps].sort((a, b) => a.stepIndex - b.stepIndex)) { - const phase = resolvePhase(step); - const existing = sections.get(phase.key) ?? { - key: phase.key, - name: phase.name, - position: phase.position, - steps: [], - }; - existing.steps.push(step); - sections.set(phase.key, existing); - } - - return [...sections.values()].sort((a, b) => a.position - b.position || a.steps[0]!.stepIndex - b.steps[0]!.stepIndex); -} - -function parsePlanArtifact(artifact: MissionArtifact | null): Partial<PlannerReview> | null { - const description = toText(artifact?.description); - if (!description) return null; - - const matchSection = (heading: string): string[] => { - const expression = new RegExp(`${heading}:\\n([\\s\\S]*?)(?:\\n\\n[A-Z][A-Za-z ]+:|$)`, "i"); - const block = description.match(expression)?.[1] ?? ""; - return block - .split("\n") - .map((line) => line.replace(/^\s*[-*]\s*/, "").trim()) - .filter(Boolean); - }; - - return { - objective: description.match(/^Objective:\s*(.+)$/im)?.[1]?.trim() ?? null, - strategy: description.match(/^Strategy:\s*(.+)$/im)?.[1]?.trim() ?? null, - assumptions: matchSection("Assumptions"), - risks: matchSection("Risks"), - }; -} - -function derivePlannerReview(mission: MissionDetail | null, runGraph: OrchestratorRunGraph | null): PlannerReview { - const runMeta = isRecord(runGraph?.run.metadata) ? runGraph.run.metadata : null; - const plannerPlan = isRecord(runMeta?.plannerPlan) ? runMeta.plannerPlan : null; - const missionSummary = isRecord(plannerPlan?.missionSummary) ? plannerPlan.missionSummary : null; - const artifactFallback = parsePlanArtifact( - mission?.artifacts.find((artifact) => - artifact.artifactType === "plan" - || artifact.title.toLowerCase().includes("generated mission plan") - || (artifact.artifactType === "summary" && artifact.title.toLowerCase().includes("plan")) - ) ?? null, - ); - const parallelismCap = Number(missionSummary?.parallelismCap ?? Number.NaN); - const assumptions = toStringArray(plannerPlan?.assumptions); - const risks = toStringArray(plannerPlan?.risks); - - return { - title: toText(mission?.title) ?? "Mission plan", - objective: toText(missionSummary?.objective) ?? artifactFallback?.objective ?? toText(mission?.prompt), - domain: toText(missionSummary?.domain), - complexity: toText(missionSummary?.complexity), - strategy: toText(missionSummary?.strategy) ?? artifactFallback?.strategy ?? null, - parallelismCap: Number.isFinite(parallelismCap) && parallelismCap > 0 ? Math.floor(parallelismCap) : null, - parallelismRationale: toText(missionSummary?.parallelismRationale), - assumptions: assumptions.length > 0 ? assumptions : (artifactFallback?.assumptions ?? []), - risks: risks.length > 0 ? risks : (artifactFallback?.risks ?? []), - }; -} - -function derivePlanGroups(steps: OrchestratorStep[]): PlanGroup[] { - const groups = new Map<string, PlanGroup>(); - for (const step of [...steps].sort((a, b) => a.stepIndex - b.stepIndex)) { - const meta = isRecord(step.metadata) ? step.metadata : {}; - const planStep = isRecord(meta.planStep) ? meta.planStep : null; - const featureLabel = toText(meta.featureLabel) ?? toText(planStep?.featureLabel) ?? toText(planStep?.feature); - const explicitMilestone = toText(meta.milestoneName) ?? toText(meta.milestoneLabel); - const milestoneLabel = explicitMilestone - ?? ((typeof meta.stepType === "string" && meta.stepType === "milestone") ? step.title : null); - const phase = resolvePhase(step); - let kind: PlanGroup["kind"] = "phase"; - if (featureLabel) kind = "feature"; - else if (milestoneLabel) kind = "milestone"; - const label = featureLabel ?? milestoneLabel ?? phase.name; - const key = `${kind}:${label.toLowerCase()}`; - const existing = groups.get(key) ?? { key, label, kind, steps: [] }; - existing.steps.push(step); - groups.set(key, existing); - } - - return [...groups.values()].sort((a, b) => a.steps[0]!.stepIndex - b.steps[0]!.stepIndex); -} - -function kindLabel(kind: PlanGroup["kind"]): string { - switch (kind) { - case "feature": return "Feature"; - case "milestone": return "Milestone"; - case "phase": return "Phase"; - } -} - -export function shouldCompactPlanStepLists(stepCount: number): boolean { - return stepCount > LARGE_PLAN_STEP_THRESHOLD; -} - -export function getVisiblePlanSteps<T>(steps: T[], compact: boolean, expanded: boolean, limit: number): T[] { - if (!compact || expanded || steps.length <= limit) return steps; - return steps.slice(0, limit); -} - -export function buildExecutableProgressLabel(steps: Array<Pick<OrchestratorStep, "status">>): string { - if (steps.length === 0) return "0 registered executable steps"; - const succeeded = steps.filter((step) => step.status === "succeeded").length; - const failed = steps.filter((step) => step.status === "failed").length; - const canceled = steps.filter((step) => step.status === "canceled").length; - const skipped = steps.filter((step) => step.status === "skipped" || step.status === "superseded").length; - if (failed > 0 || canceled > 0 || skipped > 0) { - const suffixes = [ - failed > 0 ? `${failed} failed` : null, - canceled > 0 ? `${canceled} canceled` : null, - skipped > 0 ? `${skipped} skipped` : null, - ].filter((entry): entry is string => entry != null); - return `${succeeded}/${steps.length} registered executable steps succeeded · ${suffixes.join(" · ")}`; - } - return `${succeeded}/${steps.length} registered executable steps complete`; -} - -function SummaryList({ - title, - items, - emptyLabel, -}: { - title: string; - items: string[]; - emptyLabel: string; -}) { - return ( - <section className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - {title} - </div> - {items.length > 0 ? ( - <div className="mt-2 space-y-1.5"> - {items.map((item, index) => ( - <div key={`${title}-${index}`} className="flex gap-2 text-[11px]" style={{ color: COLORS.textPrimary }}> - <span style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}>{index + 1}.</span> - <span className="min-w-0 flex-1">{item}</span> - </div> - ))} - </div> - ) : ( - <div className="mt-2 text-[11px]" style={{ color: COLORS.textMuted }}> - {emptyLabel} - </div> - )} - </section> - ); -} - -export const PlanTab = React.memo(function PlanTab({ - mission, - runGraph, - attemptsByStep, - selectedStepId, - onStepSelect, -}: { - mission: MissionDetail | null; - runGraph: OrchestratorRunGraph | null; - attemptsByStep: Map<string, OrchestratorAttempt[]>; - selectedStepId: string | null; - onStepSelect: (stepId: string) => void; -}) { - const steps = useMemo(() => runGraph?.steps ?? [], [runGraph?.steps]); - const executableSteps = useMemo(() => filterExecutionSteps(steps), [steps]); - const phaseSections = useMemo(() => phaseSectionsFromSteps(executableSteps), [executableSteps]); - const executableProgressLabel = useMemo(() => buildExecutableProgressLabel(executableSteps), [executableSteps]); - const plannerReview = useMemo(() => derivePlannerReview(mission, runGraph), [mission, runGraph]); - const planGroups = useMemo(() => derivePlanGroups(executableSteps), [executableSteps]); - const compactPlanLists = shouldCompactPlanStepLists(executableSteps.length); - const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set()); - const [expandedPhases, setExpandedPhases] = useState<Set<string>>(() => new Set()); - - const toggleExpandedGroup = (groupKey: string) => setExpandedGroups((current) => toggleSetMember(current, groupKey)); - const toggleExpandedPhase = (phaseKey: string) => setExpandedPhases((current) => toggleSetMember(current, phaseKey)); - - if (!runGraph || steps.length === 0) { - return ( - <div className="flex flex-col items-center justify-center py-16" style={{ color: COLORS.textMuted }}> - <SquaresFour size={32} weight="regular" style={{ opacity: 0.2 }} className="mb-2" /> - <p className="text-xs" style={{ fontFamily: MONO_FONT }}>No runtime plan yet. Start a run to populate the work breakdown.</p> - {mission?.steps?.length ? ( - <p className="mt-2 text-[10px]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Mission has {mission.steps.length} seeded steps waiting for orchestration. - </p> - ) : null} - </div> - ); - } - - return ( - <div className="space-y-3 pb-3"> - <div className="px-3 py-2" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between text-[11px]" style={{ color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - <span>Planner review and work breakdown</span> - <span> - {executableProgressLabel} - </span> - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textMuted }}> - Review the planner summary first, then inspect grouped work, dependencies, and step-level ownership details below. - </div> - </div> - - <section className="space-y-3 p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-start justify-between gap-3"> - <div> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Planner summary - </div> - <div className="mt-1 text-[13px] font-semibold" style={{ color: COLORS.textPrimary }}> - {plannerReview.title} - </div> - </div> - {plannerReview.parallelismCap != null ? ( - <div - className="px-2 py-1 text-[10px] font-semibold" - style={{ color: COLORS.accent, border: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", background: "color-mix(in srgb, var(--color-accent) 12%, transparent)", fontFamily: MONO_FONT }} - > - Parallelism cap {plannerReview.parallelismCap} - </div> - ) : null} - </div> - - <div className="grid gap-3 lg:grid-cols-2"> - <div className="space-y-2"> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Objective - </div> - <div className="text-[12px]" style={{ color: COLORS.textPrimary, whiteSpace: "pre-wrap" }}> - {plannerReview.objective ?? "Planner metadata did not record a structured objective. The mission prompt remains the source of truth."} - </div> - <div className="flex flex-wrap gap-2 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {plannerReview.strategy ? <span>Strategy: {plannerReview.strategy}</span> : null} - {plannerReview.domain ? <span>Domain: {plannerReview.domain}</span> : null} - {plannerReview.complexity ? <span>Complexity: {plannerReview.complexity}</span> : null} - </div> - </div> - <div className="space-y-2"> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Parallelism rationale - </div> - <div className="text-[12px]" style={{ color: COLORS.textPrimary }}> - {plannerReview.parallelismRationale ?? "No explicit rationale was captured. Use the dependency graph and grouped steps below to judge where parallel execution is actually safe."} - </div> - </div> - </div> - </section> - - <div className="grid gap-3 lg:grid-cols-2"> - <SummaryList - title="Assumptions" - items={plannerReview.assumptions} - emptyLabel="No explicit assumptions were captured in planner metadata." - /> - <SummaryList - title="Risks" - items={plannerReview.risks} - emptyLabel="No explicit risks were captured in planner metadata." - /> - </div> - - <section className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between gap-3"> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Milestone and feature grouping - </div> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {planGroups.length} grouped view{planGroups.length === 1 ? "" : "s"} - </div> - </div> - <div className="mt-2 grid gap-2 lg:grid-cols-2"> - {planGroups.map((group) => { - const expanded = expandedGroups.has(group.key); - const visibleSteps = getVisiblePlanSteps(group.steps, compactPlanLists, expanded, GROUP_STEP_PREVIEW_LIMIT); - const hiddenCount = group.steps.length - visibleSteps.length; - const canToggle = compactPlanLists && group.steps.length > GROUP_STEP_PREVIEW_LIMIT; - return ( - <div key={group.key} className="p-2.5" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between gap-3"> - <div className="text-[11px] font-semibold" style={{ color: COLORS.textPrimary }}> - {group.label} - </div> - <div className="text-[9px] uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - {kindLabel(group.kind)} - </div> - </div> - <div className="mt-2 space-y-1"> - {visibleSteps.map((step) => ( - <button - key={step.id} - type="button" - onClick={() => onStepSelect(step.id)} - className="flex w-full items-start gap-2 text-left" - style={{ color: selectedStepId === step.id ? COLORS.accent : COLORS.textSecondary }} - > - <span style={{ color: STEP_STATUS_HEX[step.status] ?? COLORS.textMuted, fontFamily: MONO_FONT }}> - {statusGlyph(step.status)} - </span> - <span className="min-w-0 flex-1 text-[11px]">{step.title}</span> - </button> - ))} - {canToggle ? ( - <button - type="button" - onClick={() => toggleExpandedGroup(group.key)} - className="mt-1 text-left text-[10px] font-semibold uppercase tracking-[0.12em]" - style={{ color: COLORS.accent, fontFamily: MONO_FONT }} - > - {expanded ? "Show fewer" : `Show ${hiddenCount} more`} - </button> - ) : null} - </div> - </div> - ); - })} - </div> - </section> - - <section className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[10px] font-semibold uppercase tracking-[0.12em]" style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}> - Dependency DAG - </div> - <div className="mt-1 text-[11px]" style={{ color: COLORS.textMuted }}> - The graph reflects the current runtime plan. Click any node to sync the detailed step panel above. - </div> - <div className="mt-2 overflow-auto"> - <OrchestratorDAG - steps={executableSteps} - attempts={runGraph.attempts} - claims={runGraph.claims} - selectedStepId={selectedStepId} - onStepClick={onStepSelect} - runId={runGraph.run.id} - /> - </div> - </section> - - {phaseSections.map((phase) => { - const expanded = expandedPhases.has(phase.key); - const visibleSteps = getVisiblePlanSteps(phase.steps, compactPlanLists, expanded, PHASE_STEP_PREVIEW_LIMIT); - const hiddenCount = phase.steps.length - visibleSteps.length; - const canToggle = compactPlanLists && phase.steps.length > PHASE_STEP_PREVIEW_LIMIT; - return ( - <section key={phase.key} className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between"> - <div className="text-[12px] font-semibold" style={{ color: COLORS.textPrimary }}> - {phase.name} - </div> - <div className="text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {phase.steps.length} item{phase.steps.length === 1 ? "" : "s"} - </div> - </div> - - <div className="mt-2 space-y-2"> - {visibleSteps.map((step, index) => { - const attempts = attemptsByStep.get(step.id) ?? []; - const latestAttempt = attempts[0] ?? null; - const isSelected = selectedStepId === step.id; - const meta = isRecord(step.metadata) ? step.metadata : {}; - const assignedTo = typeof meta.assignedTo === "string" ? meta.assignedTo.trim() : ""; - return ( - <button - key={step.id} - type="button" - onClick={() => onStepSelect(step.id)} - className="w-full px-3 py-2 text-left transition-colors" - style={isSelected - ? { background: "color-mix(in srgb, var(--color-accent) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)" } - : { background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` } - } - > - <div className="flex items-center gap-2 text-[11px]"> - <span style={{ color: COLORS.textDim, fontFamily: MONO_FONT }}>{index + 1}.</span> - <span style={{ color: STEP_STATUS_HEX[step.status] ?? COLORS.textMuted, fontFamily: MONO_FONT }}> - {statusGlyph(step.status)} - </span> - <span className="min-w-0 flex-1 truncate" style={{ color: COLORS.textPrimary }}>{step.title}</span> - <span - className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[1px]" - style={{ - background: "color-mix(in srgb, var(--color-accent) 16%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-accent) 33%, transparent)", - color: COLORS.accent, - fontFamily: MONO_FONT, - }} - > - {stepLabel(step)} - </span> - </div> - - <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>Status: {step.status}</span> - {assignedTo ? <span>Assigned to: {assignedTo}</span> : null} - {latestAttempt?.executorKind ? <span>Executor: {latestAttempt.executorKind}</span> : null} - {step.dependencyStepIds.length > 0 ? <span>Depends on: {step.dependencyStepIds.length}</span> : null} - </div> - </button> - ); - })} - {canToggle ? ( - <button - type="button" - onClick={() => toggleExpandedPhase(phase.key)} - className="w-full px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-[0.12em]" - style={{ color: COLORS.accent, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, fontFamily: MONO_FONT }} - > - {expanded ? "Show fewer" : `Show ${hiddenCount} more ${phase.name} items`} - </button> - ) : null} - </div> - </section> - ); - })} - </div> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/PromptInspectorCard.tsx b/apps/desktop/src/renderer/components/missions/PromptInspectorCard.tsx deleted file mode 100644 index 9efd47661..000000000 --- a/apps/desktop/src/renderer/components/missions/PromptInspectorCard.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React, { useMemo, useState } from "react"; -import type { OrchestratorPromptInspector } from "../../../shared/types"; -import { COLORS, MONO_FONT, outlineButton } from "../lanes/laneDesignTokens"; - -export function PromptInspectorCard({ - inspector, - loading, - error, - title, -}: { - inspector: OrchestratorPromptInspector | null; - loading?: boolean; - error?: string | null; - title?: string; -}) { - const [expandedLayerIds, setExpandedLayerIds] = useState<Set<string>>(new Set()); - const [showFullPrompt, setShowFullPrompt] = useState(false); - - const layers = inspector?.layers ?? []; - const notes = inspector?.notes ?? []; - const toggleLayer = (layerId: string) => { - setExpandedLayerIds((prev) => { - const next = new Set(prev); - if (next.has(layerId)) next.delete(layerId); - else next.add(layerId); - return next; - }); - }; - - const summary = useMemo(() => { - if (!inspector) return null; - return `${inspector.layers.length} layers${inspector.phaseName ? ` • ${inspector.phaseName}` : ""}`; - }, [inspector]); - - return ( - <div className="space-y-3 p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between gap-3"> - <div> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - {title ?? inspector?.title ?? "Effective prompt"} - </div> - {summary ? ( - <div className="mt-1 text-[11px]" style={{ color: COLORS.textSecondary }}> - {summary}{inspector?.target ? ` • ${inspector.target}` : ""} - </div> - ) : null} - </div> - {inspector?.fullPrompt ? ( - <button - type="button" - style={outlineButton({ height: 22, padding: "0 8px", fontSize: 9 })} - onClick={() => setShowFullPrompt((prev) => !prev)} - > - {showFullPrompt ? "HIDE COMPOSED" : "SHOW COMPOSED"} - </button> - ) : null} - </div> - - {loading ? ( - <div className="text-[11px]" style={{ color: COLORS.textMuted }}> - Loading prompt composition... - </div> - ) : error ? ( - <div className="text-[11px]" style={{ color: COLORS.danger }}> - {error} - </div> - ) : inspector ? ( - <> - {notes.length > 0 ? ( - <div className="space-y-1 rounded-sm px-3 py-2" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - {notes.map((note) => ( - <div key={note} className="text-[11px]" style={{ color: COLORS.textSecondary }}> - {"\u2022"} {note} - </div> - ))} - </div> - ) : null} - - <div className="space-y-2"> - {layers.map((layer) => { - const expanded = expandedLayerIds.has(layer.id); - const preview = layer.text.length > 280 ? `${layer.text.slice(0, 280).trimEnd()}...` : layer.text; - return ( - <div key={layer.id} className="rounded-sm" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between gap-3 px-3 py-2"> - <div> - <div className="text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}> - {layer.label} - </div> - <div className="mt-1 flex flex-wrap items-center gap-2 text-[9px]" style={{ color: COLORS.textMuted, fontFamily: MONO_FONT }}> - <span>{layer.source.replace(/_/g, " ")}</span> - <span>{layer.sourceKind.replace(/_/g, " ")}</span> - <span>{layer.editable ? "editable" : "read-only"}</span> - </div> - </div> - <button - type="button" - style={outlineButton({ height: 22, padding: "0 8px", fontSize: 9 })} - onClick={() => toggleLayer(layer.id)} - > - {expanded ? "HIDE" : "SHOW"} - </button> - </div> - <div className="px-3 pb-3"> - {layer.description ? ( - <div className="mb-2 text-[11px]" style={{ color: COLORS.textSecondary }}> - {layer.description} - </div> - ) : null} - <pre - className="whitespace-pre-wrap break-words rounded-sm p-3 text-[11px]" - style={{ background: COLORS.cardBg, color: COLORS.textSecondary, fontFamily: MONO_FONT }} - > - {expanded ? layer.text : preview} - </pre> - </div> - </div> - ); - })} - </div> - {showFullPrompt && inspector.fullPrompt ? ( - <div className="rounded-sm" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div className="px-3 py-2 text-[10px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}> - Composed prompt - </div> - <pre - className="px-3 pb-3 whitespace-pre-wrap break-words text-[11px]" - style={{ color: COLORS.textSecondary, fontFamily: MONO_FONT }} - > - {inspector.fullPrompt} - </pre> - </div> - ) : null} - </> - ) : ( - <div className="text-[11px]" style={{ color: COLORS.textMuted }}> - Prompt composition is not available yet for this selection. - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/SmartBudgetPanel.tsx b/apps/desktop/src/renderer/components/missions/SmartBudgetPanel.tsx deleted file mode 100644 index d20215089..000000000 --- a/apps/desktop/src/renderer/components/missions/SmartBudgetPanel.tsx +++ /dev/null @@ -1,792 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { Info, Check, Clock, Warning } from "@phosphor-icons/react"; -import type { SmartBudgetConfig, MissionBudgetProviderSnapshot, ProviderBudgetLimits, ModelProvider } from "../../../shared/types"; -import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; -import { COLORS, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; - -type BillingContext = { - hasSubscription: boolean; - subscriptionProviders: string[]; - apiProviders: string[]; -}; - -type SmartBudgetPanelProps = { - value: SmartBudgetConfig; - onChange: (config: SmartBudgetConfig) => void; - currentSpend?: { fiveHourUsd: number; weeklyUsd: number } | null; - modelUsage?: Record<string, { inputTokens: number; outputTokens: number; costUsd: number; sessions?: number }>; - billingContext?: BillingContext; - /** Per-provider budget snapshots from the budget service */ - perProvider?: MissionBudgetProviderSnapshot[]; -}; - -const inputStyle: React.CSSProperties = { - height: 28, - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - fontSize: 12, - padding: "0 6px", - outline: "none", - borderRadius: 0, - width: 100, -}; - -const smallInputStyle: React.CSSProperties = { - ...inputStyle, - width: 72, -}; - -const STEERING_ACTIONS = [ - "Downgrade models", - "Inject conciseness", - "Warn workers", - "Skip optional", - "Reduce parallelism", - "Switch provider", -]; - -const PROVIDERS: ModelProvider[] = ["claude", "codex"]; - -function ProgressBar({ - current, - threshold, - tokenMode, -}: { - current: number; - threshold: number; - tokenMode?: boolean; -}) { - const pct = threshold > 0 ? Math.min((current / threshold) * 100, 100) : 0; - const barColor = - pct >= 80 ? COLORS.danger : pct >= 50 ? COLORS.warning : COLORS.success; - - const label = tokenMode - ? `${formatTokenCount(current)} (${Math.round(pct)}%)` - : `$${current.toFixed(2)} (${Math.round(pct)}%)`; - - return ( - <div className="flex items-center gap-2 flex-1"> - <div - style={{ - height: 4, - flex: 1, - background: COLORS.border, - borderRadius: 0, - overflow: "hidden", - }} - > - <div - style={{ - height: "100%", - width: `${pct}%`, - background: barColor, - transition: "width 0.3s ease", - }} - /> - </div> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - color: COLORS.textDim, - whiteSpace: "nowrap", - }} - > - {label} - </span> - </div> - ); -} - -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K tok`; - return `${n} tok`; -} - -function formatTimeRemaining(ms: number | null): string { - if (ms == null || ms <= 0) return "--"; - const minutes = Math.floor(ms / 60_000); - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; -} - -function Tooltip({ text, children }: { text: string; children: React.ReactNode }) { - const [show, setShow] = useState(false); - return ( - <span - className="relative inline-flex" - onMouseEnter={() => setShow(true)} - onMouseLeave={() => setShow(false)} - > - {children} - {show && ( - <div - className="absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 text-[10px] whitespace-normal max-w-xs" - style={{ - background: COLORS.cardBg, - border: `1px solid ${COLORS.border}`, - color: COLORS.textSecondary, - fontFamily: MONO_FONT, - lineHeight: 1.4, - }} - > - {text} - </div> - )} - </span> - ); -} - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( - <div - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - fontWeight: 700, - color: COLORS.textMuted, - textTransform: "uppercase", - letterSpacing: "1px", - marginBottom: 8, - }} - > - {children} - </div> - ); -} - -function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) { - return ( - <span - className={className ?? "w-24 shrink-0"} - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - fontWeight: 700, - color: COLORS.textMuted, - textTransform: "uppercase", - letterSpacing: "1px", - }} - > - {children} - </span> - ); -} - -export function SmartBudgetPanel({ - value, - onChange, - currentSpend, - modelUsage, - billingContext, - perProvider, -}: SmartBudgetPanelProps) { - const subscriptionOnly = billingContext?.hasSubscription && !billingContext?.apiProviders.length; - - const handleToggle = useCallback(() => { - onChange({ ...value, enabled: !value.enabled }); - }, [value, onChange]); - - const handleFiveHourChange = useCallback( - (raw: string) => { - const num = parseFloat(raw); - if (!isNaN(num) && num >= 0) { - onChange({ ...value, fiveHourThresholdUsd: num }); - } - }, - [value, onChange] - ); - - const handleWeeklyChange = useCallback( - (raw: string) => { - const num = parseFloat(raw); - if (!isNaN(num) && num >= 0) { - onChange({ ...value, weeklyThresholdUsd: num }); - } - }, - [value, onChange] - ); - - const handleProviderLimitChange = useCallback( - (provider: ModelProvider, field: keyof ProviderBudgetLimits, raw: string) => { - const num = parseInt(raw, 10); - if (isNaN(num) || num < 0) return; - const current = value.providerLimits ?? {}; - const provLimits = current[provider] ?? { fiveHourTokenLimit: 0, weeklyTokenLimit: 0 }; - onChange({ - ...value, - providerLimits: { - ...current, - [provider]: { ...provLimits, [field]: num }, - }, - }); - }, - [value, onChange] - ); - - const handleHardStopChange = useCallback( - (field: "fiveHourHardStopPercent" | "weeklyHardStopPercent", raw: string) => { - const num = parseInt(raw, 10); - if (isNaN(num) || num < 0 || num > 100) return; - onChange({ ...value, [field]: num }); - }, - [value, onChange] - ); - - const handleApiKeyMaxChange = useCallback( - (raw: string) => { - const num = parseFloat(raw); - if (isNaN(num) || num < 0) return; - onChange({ ...value, apiKeyMaxSpendUsd: num }); - }, - [value, onChange] - ); - - const handleDowngradeThresholdChange = useCallback( - (raw: string) => { - const num = parseInt(raw, 10); - if (isNaN(num) || num < 0 || num > 100) return; - onChange({ ...value, modelDowngradeThresholdPct: num }); - }, - [value, onChange] - ); - - const dimmed = !value.enabled; - - return ( - <div - style={{ - background: COLORS.cardBg, - border: `1px solid ${COLORS.border}`, - borderRadius: 0, - }} - > - {/* Header */} - <div className="flex items-center gap-2 px-3 py-2"> - <span style={LABEL_STYLE}>SMART TOKEN BUDGET</span> - - {/* Toggle switch */} - <button - onClick={handleToggle} - className="relative" - style={{ - width: 32, - height: 16, - background: value.enabled ? COLORS.accent : COLORS.border, - border: "none", - borderRadius: 0, - cursor: "pointer", - transition: "background 0.2s ease", - }} - > - <div - style={{ - position: "absolute", - top: 2, - left: value.enabled ? 16 : 2, - width: 12, - height: 12, - background: value.enabled ? COLORS.textPrimary : COLORS.textDim, - borderRadius: 0, - transition: "left 0.2s ease", - }} - /> - </button> - - <Tooltip text={subscriptionOnly - ? "When enabled, the orchestrator tracks token usage across subscription providers and steers model usage to stay within budget thresholds." - : "When enabled, the orchestrator will automatically steer model usage to stay within budget thresholds. It can downgrade models, inject conciseness prompts, warn workers, skip optional steps, reduce parallelism, and switch providers." - }> - <span className="cursor-help"> - <Info size={14} weight="bold" color={COLORS.textDim} /> - </span> - </Tooltip> - </div> - - {/* Body */} - <div - className="px-3 pb-3 space-y-3" - style={{ - borderTop: `1px solid ${COLORS.border}`, - opacity: dimmed ? 0.4 : 1, - pointerEvents: dimmed ? "none" : "auto", - transition: "opacity 0.2s ease", - }} - > - {/* Threshold rows */} - <div className="space-y-2 pt-2"> - {/* 5-Hour Limit */} - <div className="flex items-center gap-3"> - <FieldLabel> - {subscriptionOnly ? "5-Hour Token Budget" : "5-Hour Limit"} - </FieldLabel> - <div className="flex items-center gap-1"> - {!subscriptionOnly && ( - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 12, - color: COLORS.textDim, - }} - > - $ - </span> - )} - <input - type="number" - min={0} - step={1} - style={inputStyle} - value={value.fiveHourThresholdUsd} - onChange={(e) => handleFiveHourChange(e.target.value)} - /> - </div> - {currentSpend != null && ( - <ProgressBar - current={currentSpend.fiveHourUsd} - threshold={value.fiveHourThresholdUsd} - tokenMode={subscriptionOnly} - /> - )} - </div> - - {/* Weekly Limit */} - <div className="flex items-center gap-3"> - <FieldLabel> - {subscriptionOnly ? "Weekly Token Budget" : "Weekly Limit"} - </FieldLabel> - <div className="flex items-center gap-1"> - {!subscriptionOnly && ( - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 12, - color: COLORS.textDim, - }} - > - $ - </span> - )} - <input - type="number" - min={0} - step={5} - style={inputStyle} - value={value.weeklyThresholdUsd} - onChange={(e) => handleWeeklyChange(e.target.value)} - /> - </div> - {currentSpend != null && ( - <ProgressBar - current={currentSpend.weeklyUsd} - threshold={value.weeklyThresholdUsd} - tokenMode={subscriptionOnly} - /> - )} - </div> - </div> - - {/* Per-provider usage display */} - {perProvider && perProvider.length > 0 && ( - <div - className="pt-2" - style={{ borderTop: `1px solid ${COLORS.border}` }} - > - <SectionLabel>Per-Provider Usage</SectionLabel> - <div className="space-y-3"> - {perProvider.map((snap) => { - const fiveHrPct = snap.fiveHour.usedPct ?? 0; - const weeklyPct = snap.weekly.usedPct ?? 0; - const fiveHrColor = fiveHrPct >= 80 ? COLORS.danger : fiveHrPct >= 50 ? COLORS.warning : COLORS.success; - const weeklyColor = weeklyPct >= 80 ? COLORS.danger : weeklyPct >= 50 ? COLORS.warning : COLORS.success; - return ( - <div key={snap.provider}> - <div className="flex items-center gap-2 mb-1"> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 11, - fontWeight: 700, - color: COLORS.textSecondary, - textTransform: "uppercase", - }} - > - {snap.provider} - </span> - {snap.fiveHour.timeUntilResetMs != null && snap.fiveHour.timeUntilResetMs > 0 && ( - <span className="flex items-center gap-1"> - <Clock size={10} color={COLORS.textDim} /> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - color: COLORS.textDim, - }} - > - 5hr reset: {formatTimeRemaining(snap.fiveHour.timeUntilResetMs)} - </span> - </span> - )} - </div> - {/* 5-hour bar */} - <div className="flex items-center gap-2 mb-1"> - <span - className="w-12 shrink-0" - style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.textDim }} - > - 5hr - </span> - <div - style={{ - height: 4, - flex: 1, - background: COLORS.border, - overflow: "hidden", - }} - > - <div - style={{ - height: "100%", - width: `${Math.min(fiveHrPct, 100)}%`, - background: fiveHrColor, - transition: "width 0.3s ease", - }} - /> - </div> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - color: COLORS.textDim, - whiteSpace: "nowrap", - minWidth: 80, - textAlign: "right", - }} - > - {formatTokenCount(snap.fiveHour.usedTokens)} - {snap.fiveHour.limitTokens != null ? ` / ${formatTokenCount(snap.fiveHour.limitTokens)}` : ""} - {" "}({Math.round(fiveHrPct)}%) - </span> - </div> - {/* Weekly bar */} - <div className="flex items-center gap-2"> - <span - className="w-12 shrink-0" - style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.textDim }} - > - weekly - </span> - <div - style={{ - height: 4, - flex: 1, - background: COLORS.border, - overflow: "hidden", - }} - > - <div - style={{ - height: "100%", - width: `${Math.min(weeklyPct, 100)}%`, - background: weeklyColor, - transition: "width 0.3s ease", - }} - /> - </div> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 9, - color: COLORS.textDim, - whiteSpace: "nowrap", - minWidth: 80, - textAlign: "right", - }} - > - {formatTokenCount(snap.weekly.usedTokens)} - {snap.weekly.limitTokens != null ? ` / ${formatTokenCount(snap.weekly.limitTokens)}` : ""} - {" "}({Math.round(weeklyPct)}%) - </span> - </div> - </div> - ); - })} - </div> - </div> - )} - - {/* Per-provider tier ceilings */} - <div - className="pt-2" - style={{ borderTop: `1px solid ${COLORS.border}` }} - > - <SectionLabel>Provider Tier Ceilings</SectionLabel> - <div className="space-y-2"> - {PROVIDERS.map((provider) => { - const limits = value.providerLimits?.[provider] ?? { fiveHourTokenLimit: 0, weeklyTokenLimit: 0 }; - return ( - <div key={provider} className="flex items-center gap-3"> - <span - className="w-16 shrink-0" - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - fontWeight: 700, - color: COLORS.textSecondary, - textTransform: "uppercase", - }} - > - {provider} - </span> - <div className="flex items-center gap-1"> - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.textDim }}>5hr</span> - <input - type="number" - min={0} - step={10000} - style={smallInputStyle} - value={limits.fiveHourTokenLimit} - onChange={(e) => handleProviderLimitChange(provider, "fiveHourTokenLimit", e.target.value)} - title={`${provider} 5-hour token ceiling`} - /> - </div> - <div className="flex items-center gap-1"> - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.textDim }}>wk</span> - <input - type="number" - min={0} - step={50000} - style={smallInputStyle} - value={limits.weeklyTokenLimit} - onChange={(e) => handleProviderLimitChange(provider, "weeklyTokenLimit", e.target.value)} - title={`${provider} weekly token ceiling`} - /> - </div> - </div> - ); - })} - </div> - </div> - - {/* Hard stop controls */} - <div - className="pt-2" - style={{ borderTop: `1px solid ${COLORS.border}` }} - > - <div className="flex items-center gap-2 mb-2"> - <SectionLabel>Hard Stop Limits</SectionLabel> - <Tooltip text="When usage reaches these percentages of your tier ceiling, the mission will automatically pause. Running workers finish their current task."> - <span className="cursor-help"> - <Warning size={12} weight="bold" color={COLORS.warning} /> - </span> - </Tooltip> - </div> - <div className="space-y-2"> - <div className="flex items-center gap-3"> - <FieldLabel className="w-28 shrink-0">5hr Hard Stop</FieldLabel> - <div className="flex items-center gap-1"> - <input - type="number" - min={0} - max={100} - step={5} - style={smallInputStyle} - value={value.fiveHourHardStopPercent ?? 80} - onChange={(e) => handleHardStopChange("fiveHourHardStopPercent", e.target.value)} - /> - <span style={{ fontFamily: MONO_FONT, fontSize: 10, color: COLORS.textDim }}>%</span> - </div> - </div> - <div className="flex items-center gap-3"> - <FieldLabel className="w-28 shrink-0">Weekly Hard Stop</FieldLabel> - <div className="flex items-center gap-1"> - <input - type="number" - min={0} - max={100} - step={5} - style={smallInputStyle} - value={value.weeklyHardStopPercent ?? 95} - onChange={(e) => handleHardStopChange("weeklyHardStopPercent", e.target.value)} - /> - <span style={{ fontFamily: MONO_FONT, fontSize: 10, color: COLORS.textDim }}>%</span> - </div> - </div> - {!subscriptionOnly && ( - <div className="flex items-center gap-3"> - <FieldLabel className="w-28 shrink-0">API Key Max</FieldLabel> - <div className="flex items-center gap-1"> - <span style={{ fontFamily: MONO_FONT, fontSize: 12, color: COLORS.textDim }}>$</span> - <input - type="number" - min={0} - step={5} - style={smallInputStyle} - value={value.apiKeyMaxSpendUsd ?? 0} - onChange={(e) => handleApiKeyMaxChange(e.target.value)} - /> - </div> - </div> - )} - </div> - </div> - - {/* Model downgrade threshold */} - <div - className="pt-2" - style={{ borderTop: `1px solid ${COLORS.border}` }} - > - <div className="flex items-center gap-2 mb-2"> - <SectionLabel>Model Downgrade</SectionLabel> - <Tooltip text="When subscription usage exceeds this threshold, the orchestrator will automatically switch new workers to a cheaper model tier. Set to 0 to disable."> - <span className="cursor-help"> - <Info size={12} weight="bold" color={COLORS.textDim} /> - </span> - </Tooltip> - </div> - <div className="flex items-center gap-3"> - <FieldLabel className="w-28 shrink-0">Threshold</FieldLabel> - <div className="flex items-center gap-1"> - <input - type="number" - min={0} - max={100} - step={5} - style={smallInputStyle} - value={value.modelDowngradeThresholdPct ?? 0} - onChange={(e) => handleDowngradeThresholdChange(e.target.value)} - /> - <span style={{ fontFamily: MONO_FONT, fontSize: 10, color: COLORS.textDim }}>%</span> - </div> - <span style={{ fontFamily: MONO_FONT, fontSize: 9, color: COLORS.textDim }}> - {(value.modelDowngradeThresholdPct ?? 0) > 0 - ? `Switch to cheaper model at ${value.modelDowngradeThresholdPct}% usage` - : "Disabled — no automatic downgrade" - } - </span> - </div> - </div> - - {/* Per-model usage breakdown */} - {modelUsage && Object.keys(modelUsage).length > 0 && ( - <div - className="pt-2" - style={{ borderTop: `1px solid ${COLORS.border}` }} - > - <SectionLabel>Current Model Usage</SectionLabel> - <div className="space-y-2"> - {Object.entries(modelUsage).map(([model, usage]) => { - const totalTokens = usage.inputTokens + usage.outputTokens; - const totalBudget = value.fiveHourThresholdUsd || 1; - const pct = Math.min((usage.costUsd / totalBudget) * 100, 100); - const barColor = - pct >= 80 - ? COLORS.danger - : pct >= 50 - ? COLORS.warning - : COLORS.success; - const desc = getModelById(model) ?? resolveModelAlias(model); - const family = desc?.family ?? ""; - const isSub = billingContext?.subscriptionProviders.includes(family) ?? false; - const isApi = billingContext?.apiProviders.includes(family) ?? false; - return ( - <div key={model} className="flex items-center gap-2"> - <span - className="w-32 shrink-0 truncate flex items-center gap-1" - title={model} - > - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - color: COLORS.textSecondary, - }} - > - {model} - </span> - {billingContext && (isSub || isApi) && ( - <span - className="px-1 py-0.5" - style={{ - background: isSub ? "#22C55E18" : "#F59E0B18", - color: isSub ? "#22C55E" : "#F59E0B", - fontFamily: MONO_FONT, - fontSize: 8, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "1px", - }} - > - {isSub ? "SUB" : "API"} - </span> - )} - </span> - <div - style={{ - height: 4, - flex: 1, - background: COLORS.border, - overflow: "hidden", - }} - > - <div - style={{ - height: "100%", - width: `${pct}%`, - background: barColor, - transition: "width 0.3s ease", - }} - /> - </div> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 10, - color: COLORS.textDim, - whiteSpace: "nowrap", - }} - > - {isSub - ? `${totalTokens.toLocaleString()} tok${usage.sessions ? ` · ${usage.sessions} sessions` : ""}` - : `$${usage.costUsd.toFixed(2)} · ${totalTokens.toLocaleString()} tok` - } - </span> - </div> - ); - })} - </div> - </div> - )} - - {/* Steering actions */} - <div - className="pt-2" - style={{ borderTop: `1px solid ${COLORS.border}` }} - > - <SectionLabel>When approaching limit:</SectionLabel> - <div className="flex flex-wrap gap-x-4 gap-y-1"> - {STEERING_ACTIONS.map((action) => ( - <div key={action} className="flex items-center gap-1.5"> - <Check size={12} weight="bold" color={COLORS.success} /> - <span - style={{ - fontFamily: MONO_FONT, - fontSize: 11, - color: COLORS.textSecondary, - }} - > - {action} - </span> - </div> - ))} - </div> - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/StepDetailPanel.tsx b/apps/desktop/src/renderer/components/missions/StepDetailPanel.tsx deleted file mode 100644 index eb0790b06..000000000 --- a/apps/desktop/src/renderer/components/missions/StepDetailPanel.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import React, { useState } from "react"; -import { - SpinnerGap, - Warning, - CaretDown, -} from "@phosphor-icons/react"; -import type { - OrchestratorAttempt, - OrchestratorChatTarget, - OrchestratorClaim, - OrchestratorStep, -} from "../../../shared/types"; -import { cn } from "../ui/cn"; -import { COLORS, MONO_FONT, inlineBadge } from "../lanes/laneDesignTokens"; -import { relativeWhen } from "../../lib/format"; -import { - isRecord, - compactText, - stepIntentSummary, - resolveStepHeartbeatAt, - heartbeatAgeMinutes, - STEP_STATUS_HEX, - EXECUTOR_BADGE_HEX, - STALE_HEARTBEAT_THRESHOLD_MINUTES, -} from "./missionHelpers"; - -export const StepDetailPanel = React.memo(function StepDetailPanel({ - step, - attempts, - allSteps, - claims, - onOpenWorkerThread, - onInspectPrompt, -}: { - step: OrchestratorStep | null; - attempts: OrchestratorAttempt[]; - allSteps: OrchestratorStep[]; - claims: OrchestratorClaim[]; - onOpenWorkerThread: (target: OrchestratorChatTarget) => void; - onInspectPrompt?: (stepId: string) => void; -}) { - const [showFullOutput, setShowFullOutput] = useState(false); - - if (!step) { - return ( - <aside className="p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="text-[11px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}>STEP DETAILS</div> - <p className="mt-2 text-[11px]" style={{ color: COLORS.textMuted }}>Select a card in Board or a node in DAG to inspect worker progress.</p> - </aside> - ); - } - - const latestAttempt = attempts[0] ?? null; - const meta = isRecord(step.metadata) ? step.metadata : {}; - const stepType = typeof meta.stepType === "string" ? meta.stepType : "worker"; - const expectedSignals = Array.isArray(meta.expectedSignals) - ? meta.expectedSignals - .map((entry) => String(entry ?? "").trim()) - .filter(Boolean) - : []; - const doneCriteria = typeof meta.doneCriteria === "string" ? meta.doneCriteria.trim() : ""; - const dependencyLabels = step.dependencyStepIds - .map((depId) => allSteps.find((candidate) => candidate.id === depId)) - .filter((dep): dep is OrchestratorStep => Boolean(dep)) - .map((dep) => dep.title.trim() || dep.stepKey); - const latestHeartbeatAt = resolveStepHeartbeatAt({ step, attempts, claims }); - const resultEnvelope = latestAttempt && isRecord(latestAttempt.metadata) - ? latestAttempt.metadata.resultEnvelope - : undefined; - const resultText = typeof resultEnvelope === "string" - ? resultEnvelope - : isRecord(resultEnvelope) ? JSON.stringify(resultEnvelope, null, 2) : null; - - // Validation contract - const validationContract = isRecord(meta.validationContract) ? meta.validationContract : null; - const vcTier = validationContract && typeof validationContract.tier === "string" ? validationContract.tier : null; - const vcRequired = validationContract ? validationContract.required !== false : false; - const vcCriteria = validationContract && typeof validationContract.criteria === "string" ? validationContract.criteria : null; - const lastValidationReport = isRecord(meta.lastValidationReport) ? meta.lastValidationReport : null; - const vcVerdict = lastValidationReport && typeof lastValidationReport.verdict === "string" - ? lastValidationReport.verdict as "pass" | "fail" - : null; - const vcFindings = lastValidationReport && Array.isArray(lastValidationReport.findings) - ? (lastValidationReport.findings as unknown[]) - .map((f) => String(f ?? "").trim()) - .filter(Boolean) - : []; - - const stepHex = STEP_STATUS_HEX[step.status] ?? COLORS.textMuted; - const detailCellStyle: React.CSSProperties = { background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, padding: "4px 8px" }; - - const hbAge = heartbeatAgeMinutes(latestHeartbeatAt); - const isHeartbeatStale = hbAge !== null && hbAge >= STALE_HEARTBEAT_THRESHOLD_MINUTES; - - const isRunning = step.status === "running"; - const isWaitingForWorker = isRunning && (!latestAttempt || latestAttempt.status !== "running"); - const isWorkerInitializing = isRunning && latestAttempt?.status === "running" && !latestAttempt.executorSessionId; - - return ( - <aside className="max-h-full overflow-y-auto p-3" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}` }}> - <div className="flex items-center justify-between"> - <div className="text-[11px] font-bold uppercase tracking-[1px]" style={{ color: COLORS.textPrimary, fontFamily: MONO_FONT }}>STEP DETAILS</div> - <span className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[1px]" style={inlineBadge(stepHex)}> - {step.status} - </span> - </div> - - {isWaitingForWorker && ( - <div - className="mt-2 flex items-center gap-2 px-2 py-1.5 text-[10px]" - style={{ background: "color-mix(in srgb, var(--color-warning) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--color-warning) 30%, transparent)", color: COLORS.warning }} - > - <SpinnerGap size={14} weight="regular" className="animate-spin shrink-0" /> - <span>Waiting for worker allocation...</span> - </div> - )} - {isWorkerInitializing && ( - <div - className="mt-2 flex items-center gap-2 px-2 py-1.5 text-[10px]" - style={{ background: "color-mix(in srgb, var(--color-accent) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", color: COLORS.accent }} - > - <SpinnerGap size={14} weight="regular" className="animate-spin shrink-0" /> - <span>Initializing execution environment...</span> - </div> - )} - - {isRunning && isHeartbeatStale && ( - <div - className="mt-2 flex items-center gap-2 px-2 py-1.5 text-[10px]" - style={{ background: "color-mix(in srgb, var(--color-warning) 18%, transparent)", border: "1px solid color-mix(in srgb, var(--color-warning) 40%, transparent)", color: COLORS.warning }} - > - <Warning size={14} weight="fill" className="shrink-0" /> - <span>Heartbeat stale ({Math.round(hbAge!)}m) — worker may be stuck</span> - </div> - )} - - <div className="mt-2"> - <div className="text-xs font-medium" style={{ color: COLORS.textPrimary }}>{step.title}</div> - <div className="mt-1 min-h-[28px] text-[10px] leading-snug" style={{ color: COLORS.textMuted }}>{stepIntentSummary(step)}</div> - </div> - - <div className="mt-3 grid grid-cols-2 gap-2 text-[10px]"> - <div style={detailCellStyle}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>KEY</div> - <div className="font-medium" style={{ color: COLORS.textPrimary }}>{step.stepKey}</div> - </div> - <div style={detailCellStyle}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>TYPE</div> - <div className="font-medium" style={{ color: COLORS.textPrimary }}>{stepType}</div> - </div> - <div style={detailCellStyle}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>ATTEMPTS</div> - <div className="font-medium" style={{ color: COLORS.textPrimary }}>{attempts.length}</div> - </div> - <div style={detailCellStyle}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>DEPENDENCIES</div> - <div className="font-medium" style={{ color: COLORS.textPrimary }}>{step.dependencyStepIds.length}</div> - </div> - <div className="col-span-2" style={detailCellStyle}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>LANE</div> - <div className="font-medium" style={{ color: COLORS.textPrimary }}>{step.laneId ?? "none"}</div> - </div> - </div> - - {(dependencyLabels.length > 0 || doneCriteria || expectedSignals.length > 0) && ( - <div className="mt-3 px-2 py-2 text-[10px] space-y-1.5" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - {dependencyLabels.length > 0 && ( - <div> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>DEPENDS ON</div> - <div className="mt-0.5 leading-snug" style={{ color: COLORS.textPrimary }}>{dependencyLabels.join(", ")}</div> - </div> - )} - {doneCriteria && ( - <div> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>COMPLETION CRITERIA</div> - <div className="mt-0.5 leading-snug" style={{ color: COLORS.textPrimary }}>{compactText(doneCriteria, 220)}</div> - </div> - )} - {expectedSignals.length > 0 && ( - <div> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>EXPECTED SIGNALS</div> - <div className="mt-0.5 leading-snug" style={{ color: COLORS.textPrimary }}>{expectedSignals.slice(0, 4).join(", ")}</div> - </div> - )} - </div> - )} - - {validationContract && ( - <div className="mt-3 px-2 py-2 text-[10px] space-y-1.5" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>VALIDATION</div> - <div className="flex items-center gap-2"> - {vcTier && ( - <span className="px-1 py-0.5 text-[9px] font-bold uppercase tracking-[1px]" style={inlineBadge(COLORS.accent)}> - {vcTier} - </span> - )} - <span className="text-[9px]" style={{ color: vcRequired ? COLORS.warning : COLORS.textMuted, fontFamily: MONO_FONT }}> - {vcRequired ? "REQUIRED" : "OPTIONAL"} - </span> - {vcVerdict && ( - <span - className="px-1 py-0.5 text-[9px] font-bold uppercase tracking-[1px]" - style={inlineBadge(vcVerdict === "pass" ? COLORS.success : COLORS.danger)} - > - {vcVerdict} - </span> - )} - </div> - {vcCriteria && ( - <div className="leading-snug" style={{ color: COLORS.textSecondary }}>{compactText(vcCriteria, 220)}</div> - )} - {vcFindings.length > 0 && ( - <div className="space-y-0.5"> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>FINDINGS</div> - {vcFindings.slice(0, 5).map((finding, idx) => ( - <div key={idx} className="leading-snug" style={{ color: COLORS.textSecondary }}> - {"\u2022"} {finding} - </div> - ))} - </div> - )} - </div> - )} - - <div className="mt-3 px-2 py-2 text-[10px]" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <div style={{ color: COLORS.textMuted, fontFamily: MONO_FONT, textTransform: "uppercase", letterSpacing: "1px", fontSize: 9 }}>LATEST WORKER ATTEMPT</div> - {latestAttempt ? ( - <div className="mt-1 space-y-1"> - <div className="flex items-center justify-between"> - <span style={{ color: COLORS.textMuted }}>Executor</span> - <span className="px-1 py-0.5 text-[9px] font-bold uppercase tracking-[1px]" style={inlineBadge(EXECUTOR_BADGE_HEX[latestAttempt.executorKind] ?? COLORS.textMuted)}> - {latestAttempt.executorKind} - </span> - </div> - <div className="flex items-center justify-between"> - <span style={{ color: COLORS.textMuted }}>Status</span> - <span style={{ color: COLORS.textPrimary }}>{latestAttempt.status}</span> - </div> - <div className="flex items-center justify-between"> - <span style={{ color: COLORS.textMuted }}>Started</span> - <span style={{ color: COLORS.textPrimary }}>{latestAttempt.startedAt ? relativeWhen(latestAttempt.startedAt) : "--"}</span> - </div> - <div className="flex items-center justify-between"> - <span style={{ color: COLORS.textMuted }}>Heartbeat age</span> - <span - className="flex items-center gap-1" - style={{ color: isHeartbeatStale ? COLORS.warning : COLORS.textPrimary }} - > - {isHeartbeatStale && <Warning size={11} weight="fill" />} - {latestHeartbeatAt ? relativeWhen(latestHeartbeatAt) : "--"} - </span> - </div> - {latestAttempt.errorMessage && ( - <div className="px-1.5 py-1" style={{ border: "1px solid color-mix(in srgb, var(--color-error) 30%, transparent)", background: "color-mix(in srgb, var(--color-error) 18%, transparent)", color: COLORS.danger }}> - {compactText(latestAttempt.errorMessage, 160)} - </div> - )} - </div> - ) : ( - <div className="mt-1 flex items-center gap-2" style={{ color: COLORS.textMuted }}> - {isRunning ? ( - <> - <SpinnerGap size={12} weight="regular" className="animate-spin" /> - <span>Waiting for worker allocation...</span> - </> - ) : ( - <span>No attempt has started yet.</span> - )} - </div> - )} - </div> - - {resultText && ( - <div className="mt-3 px-2 py-2 text-[10px]" style={{ background: COLORS.recessedBg, border: `1px solid ${COLORS.border}` }}> - <button - onClick={() => setShowFullOutput(!showFullOutput)} - className="flex items-center gap-1 transition-colors w-full" - style={{ color: COLORS.textMuted }} - > - <CaretDown className={cn("h-3 w-3 transition-transform", showFullOutput && "rotate-180")} /> - <span className="font-bold uppercase tracking-[1px]" style={{ fontFamily: MONO_FONT }}>VIEW FULL OUTPUT</span> - </button> - {showFullOutput && ( - <pre className="mt-2 max-h-[300px] overflow-auto p-2 text-[10px] whitespace-pre-wrap break-all" style={{ background: COLORS.recessedBg, color: COLORS.textSecondary, fontFamily: MONO_FONT }}> - {resultText} - </pre> - )} - </div> - )} - - <div className="mt-3 space-y-1.5"> - {onInspectPrompt && ( - <button - type="button" - onClick={() => onInspectPrompt(step.id)} - className="w-full px-2 py-1.5 text-[10px] font-bold uppercase tracking-[1px] transition-colors" - style={{ background: "color-mix(in srgb, var(--color-warning) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--color-warning) 28%, transparent)", color: COLORS.warning, fontFamily: MONO_FONT }} - > - INSPECT EFFECTIVE PROMPT - </button> - )} - {latestAttempt && ( - <button - onClick={() => onOpenWorkerThread({ - kind: "worker", - runId: step.runId, - stepId: step.id, - stepKey: step.stepKey, - attemptId: latestAttempt.id, - sessionId: latestAttempt.executorSessionId ?? null, - laneId: step.laneId ?? null - })} - className="w-full px-2 py-1.5 text-[10px] font-bold uppercase tracking-[1px] transition-colors" - style={{ background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", border: "1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)", color: COLORS.accent, fontFamily: MONO_FONT }} - > - JUMP TO WORKER CHANNEL - </button> - )} - </div> - </aside> - ); -}); diff --git a/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx b/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx deleted file mode 100644 index 88201fde9..000000000 --- a/apps/desktop/src/renderer/components/missions/WorkerPermissionsEditor.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import React, { useMemo } from "react"; -import { Shield, Warning } from "@phosphor-icons/react"; -import type { - PhaseCard, - MissionPermissionConfig, - AgentChatPermissionMode, -} from "../../../shared/types"; -import { resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { - getPermissionOptions, - safetyBadgeLabel, - safetyColorHex, - familyToPermissionKey, - permissionFamilyLabel, - type PermissionOption, -} from "../shared/permissionOptions"; -import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens"; - -export type PermFamilyKey = "claude" | "codex" | "cursor" | "droid" | "opencode"; - -/** Derive unique model families in use from orchestrator + phase card models */ -export function deriveActivePermFamilies( - orchestratorModelId: string | undefined, - phases: PhaseCard[], -): PermFamilyKey[] { - const seen = new Set<PermFamilyKey>(); - const modelIds = new Set<string>(); - if (orchestratorModelId) modelIds.add(orchestratorModelId); - for (const phase of phases) { - if (phase.model?.modelId) modelIds.add(phase.model.modelId); - } - for (const id of modelIds) { - const desc = resolveModelDescriptor(id); - if (desc) { - seen.add(familyToPermissionKey(desc.family, desc.isCliWrapped)); - } - } - const order: PermFamilyKey[] = ["claude", "codex", "cursor", "droid", "opencode"]; - return order.filter((k) => seen.has(k)); -} - -export type WorkerPermissionsEditorProps = { - orchestratorModelId: string | undefined; - phases: PhaseCard[]; - permissionConfig: MissionPermissionConfig; - onPermissionChange: (next: MissionPermissionConfig) => void; - labelStyle?: React.CSSProperties; - inputStyle?: React.CSSProperties; - title?: string; - description?: string; -}; - -const DEFAULT_LABEL_STYLE: React.CSSProperties = { - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase" as const, - letterSpacing: "1px", - color: COLORS.textMuted, -}; - -const DEFAULT_INPUT_STYLE: React.CSSProperties = { - height: 28, - width: "100%", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - padding: "0 8px", - fontSize: 11, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - borderRadius: 0, - outline: "none", -}; - -export function WorkerPermissionsEditor({ - orchestratorModelId, - phases, - permissionConfig, - onPermissionChange, - labelStyle: _lblStyle, - inputStyle: inpStyle, - title, - description, -}: WorkerPermissionsEditorProps) { - const families = useMemo( - () => deriveActivePermFamilies(orchestratorModelId, phases), - [orchestratorModelId, phases], - ); - - const provPerms = permissionConfig?.providers; - - const familyOptions = useMemo(() => { - const map = new Map<PermFamilyKey, PermissionOption[]>(); - for (const fam of families) { - const modelFamily = fam === "claude" - ? "anthropic" - : fam === "codex" - ? "openai" - : fam === "droid" - ? "factory" - : "opencode"; - const isCliWrapped = fam === "claude" || fam === "codex" || fam === "droid"; - map.set(fam, getPermissionOptions({ family: modelFamily, isCliWrapped })); - } - return map; - }, [families]); - - const updateProviderPerm = (key: PermFamilyKey, value: AgentChatPermissionMode) => { - const codexSandbox = value === "full-auto" - ? "danger-full-access" - : value === "plan" - ? "read-only" - : "workspace-write"; - onPermissionChange({ - ...permissionConfig, - providers: { - ...permissionConfig?.providers, - [key]: value, - ...(key === "codex" && value !== "config-toml" ? { codexSandbox } : {}), - }, - }); - }; - - const hasRestricted = families.some((f) => { - const mode = provPerms?.[f] ?? "full-auto"; - return mode !== "full-auto"; - }); - - const inputStyleResolved = inpStyle ?? DEFAULT_INPUT_STYLE; - - return ( - <div className="space-y-2"> - <span style={_lblStyle ?? DEFAULT_LABEL_STYLE}> - <Shield size={12} weight="bold" className="inline mr-1 -mt-0.5" style={{ color: COLORS.textMuted }} /> - {title ?? "WORKER PERMISSIONS"} - </span> - {description ? ( - <div style={{ fontSize: 10, color: COLORS.textDim, fontFamily: MONO_FONT, lineHeight: "1.5" }}> - {description} - </div> - ) : null} - - <div className="space-y-2"> - {families.length === 0 && ( - <div style={{ fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textMuted, padding: "8px 0" }}> - Select an orchestrator model and phase models to configure permissions. - </div> - )} - {families.map((fam) => { - const opts = familyOptions.get(fam) ?? []; - const current = provPerms?.[fam] ?? "full-auto"; - const selected = opts.find((o) => o.value === current) ?? opts[opts.length - 1]; - - return ( - <div - key={fam} - style={{ - background: COLORS.recessedBg, - border: `1px solid ${COLORS.border}`, - padding: "10px 12px", - }} - > - {/* Family header */} - <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}> - <span style={{ fontSize: 10, fontWeight: 700, fontFamily: MONO_FONT, textTransform: "uppercase" as const, letterSpacing: "1px", color: COLORS.textPrimary }}> - {permissionFamilyLabel(fam)} - </span> - {selected && ( - <span - style={{ - fontSize: 9, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase" as const, - letterSpacing: "1px", - color: safetyColorHex(selected.safety), - marginLeft: "auto", - }} - > - {safetyBadgeLabel(selected.safety)} - </span> - )} - </div> - - {/* Permission dropdown */} - <select - value={current} - onChange={(e) => updateProviderPerm(fam, e.target.value as AgentChatPermissionMode)} - className="h-7 w-full px-2 outline-none" - style={inputStyleResolved} - > - {opts.map((opt) => ( - <option key={opt.value} value={opt.value}> - {opt.label} — {opt.shortDesc} - </option> - ))} - </select> - - {/* Description of selected mode */} - {selected && ( - <div style={{ fontSize: 10, color: COLORS.textDim, fontFamily: MONO_FONT, marginTop: 6, lineHeight: "1.5" }}> - {selected.detail} - </div> - )} - - {/* Warning for selected mode */} - {selected?.warning && ( - <div - style={{ - fontSize: 10, - color: COLORS.danger, - fontFamily: MONO_FONT, - marginTop: 6, - display: "flex", - alignItems: "center", - gap: 4, - }} - > - <Warning size={12} weight="bold" /> - {selected.warning} - </div> - )} - </div> - ); - })} - </div> - - {hasRestricted && ( - <div - style={{ - fontSize: 10, - color: COLORS.warning, - fontFamily: MONO_FONT, - display: "flex", - alignItems: "center", - gap: 4, - }} - > - <Warning size={12} weight="bold" /> - Workers using restricted permissions may pause for approval during autonomous execution. - </div> - )} - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/missions/chatFilters.ts b/apps/desktop/src/renderer/components/missions/chatFilters.ts deleted file mode 100644 index 873c7ab54..000000000 --- a/apps/desktop/src/renderer/components/missions/chatFilters.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Chat filtering utilities — extracted from MissionChatV2. - * - * Pure functions used by the chat message area to classify, - * filter and format orchestrator chat messages. - */ -import type { OrchestratorChatMessage, OrchestratorChatTarget } from "../../../shared/types"; -import type { MentionParticipant } from "../shared/MentionInput"; -import { COLORS } from "../lanes/laneDesignTokens"; -import { looksLikeLowSignalNoise } from "./missionHelpers"; - -// ── Design token shortcuts ── -const STATUS_GREEN = COLORS.success; -const STATUS_WARNING = COLORS.warning; -const STATUS_GRAY = "#6b7280"; -const STATUS_RED = COLORS.danger; - -// ── Helpers ── - -export function readRecord(value: unknown): Record<string, unknown> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record<string, unknown>) - : null; -} - -export function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -export function formatStructuredValue(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -// ── Signal detection ── - -function isUsefulStructuredSignal( - msg: OrchestratorChatMessage, - kind: string, - structured: Record<string, unknown>, -): boolean { - if (kind === "plan" || kind === "approval_request" || kind === "user_message") return true; - if (kind === "text" || kind === "reasoning") return !looksLikeLowSignalNoise(msg.content); - if (kind === "status") { - const status = readString(structured.status)?.toLowerCase() ?? ""; - const message = readString(structured.message) ?? msg.content; - if (status === "failed" || status === "interrupted") return true; - return message.length > 0 && !looksLikeLowSignalNoise(message); - } - if (kind === "error") { - const errorMessage = readString(structured.message) ?? msg.content; - return errorMessage.length > 0 && !looksLikeLowSignalNoise(errorMessage); - } - return false; -} - -/** - * Determine whether a chat message carries meaningful signal - * (versus low-value noise like "streaming…" or metadata-only - * records). - */ -export function isSignalMessage(msg: OrchestratorChatMessage): boolean { - if (msg.visibility === "metadata_only") return false; - if (msg.role === "user") return true; - const metadata = readRecord(msg.metadata); - const structured = readRecord(metadata?.structuredStream); - const kind = readString(structured?.kind); - if (kind) { - return isUsefulStructuredSignal(msg, kind, structured ?? {}); - } - const content = typeof msg.content === "string" ? msg.content : ""; - return !looksLikeLowSignalNoise(content); -} - -/** - * Collapse consecutive planner-stream fragments that share the - * same threadId + sourceSessionId into a single message so the - * UI doesn't show dozens of tiny chunks. - * - * Re-exported from missionHelpers for convenience. - */ -export { collapsePlannerStreamMessages } from "./missionHelpers"; - -// ── Worker status helpers ── - -export function statusDotForWorker(state?: string): string { - if (!state) return STATUS_GRAY; - switch (state) { - case "spawned": - case "initializing": - case "working": - return STATUS_GREEN; - case "waiting_input": - return STATUS_WARNING; - case "completed": - case "idle": - case "disposed": - return STATUS_GRAY; - case "failed": - return STATUS_RED; - default: - return STATUS_GRAY; - } -} - -export function workerStatusToParticipantStatus( - state?: string, -): "active" | "completed" | "failed" { - if (!state) return "completed"; - switch (state) { - case "spawned": - case "initializing": - case "working": - case "waiting_input": - return "active"; - case "failed": - return "failed"; - default: - return "completed"; - } -} - -// ── Mention helpers ── - -export type MentionTargetOption = MentionParticipant & { - threadId: string | null; - target: OrchestratorChatTarget | null; - helper: string; -}; - -export function normalizeMentionKey( - value: string, - fallback: string, - used: Set<string>, -): string { - const base = - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, "") || fallback; - let candidate = base; - let suffix = 2; - while (used.has(candidate)) { - candidate = `${base}-${suffix}`; - suffix += 1; - } - used.add(candidate); - return candidate; -} diff --git a/apps/desktop/src/renderer/components/missions/missionChatChannelModel.test.ts b/apps/desktop/src/renderer/components/missions/missionChatChannelModel.test.ts deleted file mode 100644 index e2361db04..000000000 --- a/apps/desktop/src/renderer/components/missions/missionChatChannelModel.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OrchestratorChatThread, OrchestratorWorkerState } from "../../../shared/types"; -import { - isLiveMissionWorkerState, - resolveChatTargetChannelId, - resolveWorkerThreadChannelStatus, - shouldShowWorkerStreamingIndicator, -} from "./missionChatChannelModel"; - -function workerState(state: OrchestratorWorkerState["state"]): OrchestratorWorkerState { - return { - attemptId: "attempt-1", - stepId: "step-1", - runId: "run-1", - sessionId: "session-1", - executorKind: "codex", - state, - lastHeartbeatAt: "2026-05-08T00:00:00.000Z", - spawnedAt: "2026-05-08T00:00:00.000Z", - completedAt: null, - outcomeTags: [], - }; -} - -function thread(overrides: Partial<OrchestratorChatThread>): OrchestratorChatThread { - return { - id: "thread-1", - missionId: "mission-1", - threadType: "worker", - title: "Worker", - runId: "run-1", - stepId: "step-1", - stepKey: "step-key", - attemptId: "attempt-1", - sessionId: "session-1", - laneId: "lane-1", - status: "active", - unreadCount: 0, - createdAt: "2026-05-08T00:00:00.000Z", - updatedAt: "2026-05-08T00:00:00.000Z", - metadata: null, - ...overrides, - }; -} - -describe("mission chat channel model", () => { - it("treats only live worker states as live", () => { - expect(isLiveMissionWorkerState("spawned")).toBe(true); - expect(isLiveMissionWorkerState("initializing")).toBe(true); - expect(isLiveMissionWorkerState("working")).toBe(true); - expect(isLiveMissionWorkerState("waiting_input")).toBe(true); - expect(isLiveMissionWorkerState("completed")).toBe(false); - expect(isLiveMissionWorkerState("failed")).toBe(false); - expect(isLiveMissionWorkerState("disposed")).toBe(false); - }); - - it("does not keep stale retry attempts in the active worker list without live state", () => { - expect(resolveWorkerThreadChannelStatus({ - threadStatus: "active", - workerState: undefined, - runStatus: "active", - })).toBe("closed"); - }); - - it("keeps an active thread active when the worker state is live", () => { - expect(resolveWorkerThreadChannelStatus({ - threadStatus: "active", - workerState: workerState("working"), - runStatus: "active", - })).toBe("active"); - }); - - it("resolves worker jump targets to hydrated thread channel ids", () => { - expect(resolveChatTargetChannelId({ - target: { kind: "worker", attemptId: "attempt-1" }, - threads: [thread({ id: "worker-thread-1" })], - })).toBe("thread:worker-thread-1"); - }); - - it("prefers exact worker identifiers over broad lane matches", () => { - expect(resolveChatTargetChannelId({ - target: { - kind: "worker", - stepId: "planning-step", - stepKey: "planning-worker", - laneId: "shared-lane", - }, - threads: [ - thread({ - id: "testing-thread", - title: "Testing worker", - stepId: "testing-step", - stepKey: "testing-worker", - laneId: "shared-lane", - }), - thread({ - id: "planning-thread", - title: "Planning worker", - stepId: "planning-step", - stepKey: "planning-worker", - laneId: "shared-lane", - }), - ], - })).toBe("thread:planning-thread"); - }); - - it("does not use a shared lane fallback when a precise worker target misses", () => { - expect(resolveChatTargetChannelId({ - target: { - kind: "worker", - stepId: "missing-step", - laneId: "shared-lane", - }, - threads: [ - thread({ - id: "testing-thread", - stepId: "testing-step", - laneId: "shared-lane", - }), - ], - })).toBeNull(); - }); - - it("waits for worker threads to hydrate before selecting a worker target", () => { - expect(resolveChatTargetChannelId({ - target: { kind: "worker", attemptId: "attempt-1" }, - threads: [], - })).toBeNull(); - }); - - it("does not show a streaming indicator for closed worker history", () => { - expect(shouldShowWorkerStreamingIndicator({ - channelKind: "worker", - channelStatus: "closed", - attemptId: "attempt-1", - workerState: "working", - })).toBe(false); - }); - - it("shows a streaming indicator only for active live worker threads", () => { - expect(shouldShowWorkerStreamingIndicator({ - channelKind: "worker", - channelStatus: "active", - attemptId: "attempt-1", - workerState: "working", - })).toBe(true); - expect(shouldShowWorkerStreamingIndicator({ - channelKind: "worker", - channelStatus: "active", - attemptId: "attempt-1", - workerState: "completed", - })).toBe(false); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/missionChatChannelModel.ts b/apps/desktop/src/renderer/components/missions/missionChatChannelModel.ts deleted file mode 100644 index d3d9ac3aa..000000000 --- a/apps/desktop/src/renderer/components/missions/missionChatChannelModel.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - OrchestratorChatTarget, - OrchestratorChatThread, - OrchestratorRunStatus, - OrchestratorWorkerState, -} from "../../../shared/types"; - -export function isLiveMissionWorkerState(state: OrchestratorWorkerState["state"] | null | undefined): boolean { - return state === "spawned" - || state === "initializing" - || state === "working" - || state === "waiting_input"; -} - -export function resolveWorkerThreadChannelStatus(args: { - threadStatus: OrchestratorChatThread["status"]; - workerState: OrchestratorWorkerState | undefined; - runStatus: OrchestratorRunStatus | null; -}): "active" | "closed" { - const { threadStatus, workerState, runStatus } = args; - if (runStatus === "succeeded" || runStatus === "failed" || runStatus === "canceled") return "closed"; - if (threadStatus !== "active") return "closed"; - if (!workerState) return "closed"; - return isLiveMissionWorkerState(workerState.state) ? "active" : "closed"; -} - -export function shouldShowWorkerStreamingIndicator(args: { - channelKind: string | null | undefined; - channelStatus: "active" | "closed" | null | undefined; - attemptId: string | null | undefined; - workerState: OrchestratorWorkerState["state"] | null | undefined; -}): boolean { - if (args.channelKind !== "worker") return false; - if (args.channelStatus !== "active") return false; - if (!trimmed(args.attemptId)) return false; - return args.workerState === "initializing" || args.workerState === "working"; -} - -function trimmed(value: string | null | undefined): string { - return typeof value === "string" ? value.trim() : ""; -} - -function metadataString(thread: OrchestratorChatThread, key: string): string { - const value = thread.metadata?.[key]; - return typeof value === "string" ? value.trim() : ""; -} - -function threadChannelId(thread: OrchestratorChatThread | null | undefined): string | null { - return thread ? `thread:${thread.id}` : null; -} - -export function resolveChatTargetChannelId(args: { - target: OrchestratorChatTarget; - threads: OrchestratorChatThread[]; -}): string | null { - const { target, threads } = args; - - if (target.kind === "coordinator") { - const sessionId = trimmed(target.sessionId); - const laneId = trimmed(target.laneId); - const thread = threads.find((candidate) => { - if (candidate.threadType !== "coordinator") return false; - if (sessionId && trimmed(candidate.sessionId) !== sessionId) return false; - if (laneId && trimmed(candidate.laneId) !== laneId) return false; - return true; - }); - return threadChannelId(thread); - } - - if (target.kind === "teammate") { - const sessionId = trimmed(target.sessionId); - const teamMemberId = trimmed(target.teamMemberId); - const thread = threads.find((candidate) => { - if (candidate.threadType !== "teammate") return false; - if (sessionId && trimmed(candidate.sessionId) !== sessionId) return false; - if (teamMemberId && metadataString(candidate, "teamMemberId") !== teamMemberId) return false; - return true; - }); - return threadChannelId(thread); - } - - if (target.kind === "worker") { - const attemptId = trimmed(target.attemptId); - const stepId = trimmed(target.stepId); - const sessionId = trimmed(target.sessionId); - const stepKey = trimmed(target.stepKey); - const laneId = trimmed(target.laneId); - if (!attemptId && !stepId && !sessionId && !stepKey && !laneId) return null; - - const workerThreads = threads.filter((candidate) => candidate.threadType === "worker"); - const thread = - (attemptId ? workerThreads.find((candidate) => trimmed(candidate.attemptId) === attemptId) : null) - ?? (stepId ? workerThreads.find((candidate) => trimmed(candidate.stepId) === stepId) : null) - ?? (sessionId ? workerThreads.find((candidate) => trimmed(candidate.sessionId) === sessionId) : null) - ?? (stepKey ? workerThreads.find((candidate) => trimmed(candidate.stepKey) === stepKey) : null) - ?? (!attemptId && !stepId && !sessionId && !stepKey && laneId - ? workerThreads.find((candidate) => trimmed(candidate.laneId) === laneId) - : null); - return threadChannelId(thread); - } - - if (target.kind === "workers") { - const laneId = trimmed(target.laneId); - const thread = threads.find((candidate) => ( - candidate.threadType === "worker" - && (!laneId || trimmed(candidate.laneId) === laneId) - )); - return threadChannelId(thread); - } - - if (target.kind === "agent") { - const targetAttemptId = trimmed(target.targetAttemptId); - if (!targetAttemptId) return null; - const thread = threads.find((candidate) => ( - candidate.threadType === "worker" - && trimmed(candidate.attemptId) === targetAttemptId - )); - return threadChannelId(thread); - } - - return null; -} diff --git a/apps/desktop/src/renderer/components/missions/missionControlViewModel.test.ts b/apps/desktop/src/renderer/components/missions/missionControlViewModel.test.ts deleted file mode 100644 index b59d2bde6..000000000 --- a/apps/desktop/src/renderer/components/missions/missionControlViewModel.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - MissionDetail, - OrchestratorArtifact, - OrchestratorRunGraph, - OrchestratorWorkerCheckpoint, - PhaseCard, -} from "../../../shared/types"; -import { - buildMissionArtifactGroups, - deriveActivePhaseViewModel, -} from "./missionControlViewModel"; - -function makePhase(overrides: Partial<PhaseCard> = {}): PhaseCard { - return { - id: "phase-planning", - phaseKey: "planning", - name: "Planning", - description: "Plan the work", - instructions: "Research before implementation.", - model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-09T00:00:00.000Z", - updatedAt: "2026-03-09T00:00:00.000Z", - ...overrides, - }; -} - -function makeMission(phases: PhaseCard[]): MissionDetail { - return { - id: "mission-1", - title: "Mission", - prompt: "Ship it", - status: "in_progress", - priority: "normal", - executionMode: "local", - targetMachineId: null, - outcomeSummary: null, - lastError: null, - artifactCount: 0, - openInterventions: 0, - laneId: "lane-1", - laneName: "mission/lane-1", - totalSteps: 2, - completedSteps: 0, - startedAt: "2026-03-09T00:00:00.000Z", - completedAt: null, - createdAt: "2026-03-09T00:00:00.000Z", - updatedAt: "2026-03-09T00:00:00.000Z", - artifacts: [], - interventions: [], - steps: [], - events: [], - phaseConfiguration: { - profile: null, - override: null, - selectedPhases: phases, - }, - }; -} - -function makeRunGraph(phases: PhaseCard[]): OrchestratorRunGraph { - return { - run: { - id: "run-1", - missionId: "mission-1", - projectId: "project-1", - status: "active", - contextProfile: "orchestrator_deterministic_v1", - schedulerState: "active", - createdAt: "2026-03-09T00:00:00.000Z", - updatedAt: "2026-03-09T00:00:00.000Z", - startedAt: "2026-03-09T00:00:00.000Z", - completedAt: null, - lastError: null, - metadata: { - missionGoal: "Ship it", - phaseOverride: phases, - phaseRuntime: { - currentPhaseKey: "validation", - currentPhaseName: "Validation", - }, - }, - }, - steps: [ - { - id: "step-plan", - runId: "run-1", - missionStepId: null, - stepKey: "plan", - stepIndex: 0, - title: "Planning step", - laneId: "lane-1", - status: "succeeded", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: [], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-09T00:00:00.000Z", - updatedAt: "2026-03-09T00:00:00.000Z", - startedAt: "2026-03-09T00:00:00.000Z", - completedAt: "2026-03-09T00:01:00.000Z", - metadata: { phaseKey: "planning", phaseName: "Planning", phasePosition: 1 }, - }, - { - id: "step-validate", - runId: "run-1", - missionStepId: null, - stepKey: "validate", - stepIndex: 1, - title: "Validation step", - laneId: "lane-1", - status: "running", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: ["step-plan"], - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-09T00:00:00.000Z", - updatedAt: "2026-03-09T00:00:00.000Z", - startedAt: "2026-03-09T00:01:00.000Z", - completedAt: null, - metadata: { phaseKey: "validation", phaseName: "Validation", phasePosition: 2 }, - }, - ], - attempts: [], - claims: [], - contextSnapshots: [], - handoffs: [], - timeline: [], - runtimeEvents: [], - }; -} - -describe("deriveActivePhaseViewModel", () => { - it("resolves the active phase from the run snapshot and exposes exit requirements", () => { - const phases = [ - makePhase(), - makePhase({ - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - position: 2, - validationGate: { - tier: "dedicated", - required: true, - evidenceRequirements: ["screenshot"], - capabilityFallback: "block", - }, - }), - ]; - const vm = deriveActivePhaseViewModel({ - mission: makeMission(phases), - runGraph: makeRunGraph(phases), - modelCapabilities: { profiles: [] }, - }); - - expect(vm?.phase?.phaseKey).toBe("validation"); - expect(vm?.position).toBe(2); - expect(vm?.validationRequired).toBe(true); - expect(vm?.exitRequirements.some((entry) => entry.includes("remaining validation step"))).toBe(true); - expect(vm?.capabilityWarnings[0]).toContain("screenshot"); - }); - - it("does not treat display-only task shells as remaining phase work", () => { - const phases = [ - makePhase(), - makePhase({ - id: "phase-development", - phaseKey: "development", - name: "Development", - position: 2, - }), - ]; - const runGraph = makeRunGraph(phases); - runGraph.run.metadata = { - ...runGraph.run.metadata, - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - }, - }; - const planningStep = runGraph.steps[0]!; - runGraph.steps = [ - planningStep, - { - ...planningStep, - id: "step-task-shell", - stepKey: "development-readonly-noop-verification", - stepIndex: 1, - title: "Read-only no-op development verification", - status: "pending", - dependencyStepIds: [planningStep.id], - startedAt: null, - completedAt: null, - metadata: { - phaseKey: "development", - phaseName: "Development", - phasePosition: 2, - stepType: "task", - taskType: "implementation", - }, - }, - { - ...planningStep, - id: "step-development-worker", - stepKey: "worker-development", - stepIndex: 2, - title: "Development worker", - status: "succeeded", - dependencyStepIds: [planningStep.id], - metadata: { - phaseKey: "development", - phaseName: "Development", - phasePosition: 2, - stepType: "implementation", - taskType: "implementation", - spawnedByCoordinator: true, - workerName: "Development worker", - }, - }, - ]; - - const vm = deriveActivePhaseViewModel({ - mission: makeMission(phases), - runGraph, - modelCapabilities: { profiles: [] }, - }); - - expect(vm?.whyActive).not.toContain("non-terminal work"); - expect(vm?.exitRequirements.join("\n")).not.toContain("remaining step"); - expect(vm?.exitRequirements).toContain("All executable Development steps are terminal. ADE can advance after coordinator review."); - }); - - it("does not show active phase instructions after the mission is canceled", () => { - const phases = [makePhase()]; - const mission = makeMission(phases); - mission.status = "canceled"; - const runGraph = makeRunGraph(phases); - runGraph.run.status = "canceled"; - runGraph.run.metadata = { - ...runGraph.run.metadata, - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - }; - - const vm = deriveActivePhaseViewModel({ - mission, - runGraph, - modelCapabilities: { profiles: [] }, - }); - - expect(vm?.modeLabel).toBe("closed"); - expect(vm?.whyActive).toContain("Mission canceled"); - expect(vm?.exitRequirements).toEqual([]); - }); - - it("does not count phase approvals as open planning questions", () => { - const phases = [makePhase({ requiresApproval: true })]; - const mission = makeMission(phases); - mission.interventions = [ - { - id: "intervention-phase-approval", - missionId: mission.id, - laneId: mission.laneId, - interventionType: "phase_approval", - status: "open", - title: "Approve transition from Planning phase", - body: "Approve the planning output.", - requestedAction: "Approve the Planning output to proceed to Development.", - metadata: { source: "phase_approval_gate", phaseKey: "planning" }, - createdAt: "2026-03-09T00:02:00.000Z", - updatedAt: "2026-03-09T00:02:00.000Z", - resolvedAt: null, - resolutionKind: null, - resolutionNote: null, - }, - ]; - const runGraph = makeRunGraph(phases); - runGraph.run.metadata = { - ...runGraph.run.metadata, - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - }; - runGraph.steps = [ - { - ...runGraph.steps[0]!, - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", phasePosition: 1 }, - }, - ]; - - const vm = deriveActivePhaseViewModel({ - mission, - runGraph, - modelCapabilities: { profiles: [] }, - }); - - expect(vm?.exitRequirements.join("\n")).not.toContain("open planning question"); - expect(vm?.exitRequirements.join("\n")).toContain("approve the planning phase"); - }); - - it("counts planner manual-input interventions as open planning questions", () => { - const phases = [makePhase({ requiresApproval: true })]; - const mission = makeMission(phases); - mission.interventions = [ - { - id: "intervention-planner-question", - missionId: mission.id, - laneId: mission.laneId, - interventionType: "manual_input", - status: "open", - title: "Planning clarification needed", - body: "Should screenshot proof block closeout?", - requestedAction: "Answer the planner question.", - metadata: { - source: "ask_user", - reasonCode: "planner_required_question_missing", - phaseKey: "planning", - questionOwnerKind: "planner", - }, - createdAt: "2026-03-09T00:02:00.000Z", - updatedAt: "2026-03-09T00:02:00.000Z", - resolvedAt: null, - resolutionKind: null, - resolutionNote: null, - }, - ]; - const runGraph = makeRunGraph(phases); - runGraph.run.metadata = { - ...runGraph.run.metadata, - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - }, - }; - runGraph.steps = [ - { - ...runGraph.steps[0]!, - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", phasePosition: 1 }, - }, - ]; - - const vm = deriveActivePhaseViewModel({ - mission, - runGraph, - modelCapabilities: { profiles: [] }, - }); - - expect(vm?.exitRequirements.join("\n")).toContain("Answer 1 open planning question(s)."); - expect(vm?.exitRequirements.join("\n")).toContain("approve the planning phase"); - }); -}); - -describe("buildMissionArtifactGroups", () => { - it("merges mission artifacts, orchestrator artifacts, checkpoints, and missing expected evidence", () => { - const phases = [ - makePhase({ - id: "phase-validation", - phaseKey: "validation", - name: "Validation", - validationGate: { - tier: "dedicated", - required: true, - evidenceRequirements: ["screenshot", "console_logs"], - capabilityFallback: "block", - }, - }), - ]; - const mission = makeMission(phases); - mission.artifacts = [ - { - id: "mission-artifact-1", - missionId: "mission-1", - artifactType: "summary", - title: "Planning summary", - description: "Planner notes", - uri: null, - laneId: "lane-1", - createdBy: "planner", - createdAt: "2026-03-09T00:00:00.000Z", - updatedAt: "2026-03-09T00:00:00.000Z", - metadata: { phaseKey: "planning", phaseName: "Planning" }, - }, - ]; - const runGraph = makeRunGraph(phases); - const orchestratorArtifacts: OrchestratorArtifact[] = [ - { - id: "orch-artifact-1", - missionId: "mission-1", - runId: "run-1", - stepId: "step-validate", - attemptId: "attempt-1", - artifactKey: "console_logs", - kind: "custom", - value: "Console captured", - metadata: {}, - declared: true, - createdAt: "2026-03-09T00:02:00.000Z", - }, - ]; - const checkpoints: OrchestratorWorkerCheckpoint[] = [ - { - id: "checkpoint-1", - missionId: "mission-1", - runId: "run-1", - stepId: "step-validate", - attemptId: "attempt-1", - stepKey: "validate", - content: "Checkpoint body", - filePath: "/tmp/checkpoint.md", - createdAt: "2026-03-09T00:03:00.000Z", - updatedAt: "2026-03-09T00:03:00.000Z", - }, - ]; - - const grouped = buildMissionArtifactGroups({ - mission, - runGraph, - orchestratorArtifacts, - checkpoints, - }); - - expect(grouped.all.some((entry) => entry.source === "mission")).toBe(true); - expect(grouped.all.some((entry) => entry.source === "checkpoint")).toBe(true); - expect(grouped.all.some((entry) => entry.artifactType === "console_logs")).toBe(true); - expect(grouped.all.some((entry) => entry.artifactType === "screenshot" && entry.missingExpectedEvidence)).toBe(true); - expect(grouped.byWorker.some((group) => group.key === "attempt-1" && group.label === "Validation step")).toBe(true); - expect(grouped.byWorker.some((group) => group.key === "planner" && group.label === "planner")).toBe(true); - }); - - it("prefers orchestrator artifact metadata titles when present", () => { - const phases = [makePhase()]; - const runGraph = makeRunGraph(phases); - const grouped = buildMissionArtifactGroups({ - mission: makeMission(phases), - runGraph, - orchestratorArtifacts: [ - { - id: "orch-artifact-2", - missionId: "mission-1", - runId: "run-1", - stepId: "step-plan", - attemptId: "attempt-2", - artifactKey: "step_summary", - kind: "custom", - value: "Planner discovered two workstreams.", - metadata: { - title: "Planner output", - summary: "Planner discovered two workstreams.", - }, - declared: false, - createdAt: "2026-03-09T00:04:00.000Z", - }, - ], - checkpoints: [], - }); - - expect(grouped.all[0]?.title).toBe("Planner output"); - expect(grouped.all[0]?.description).toBe("Planner discovered two workstreams."); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/missionControlViewModel.ts b/apps/desktop/src/renderer/components/missions/missionControlViewModel.ts deleted file mode 100644 index 9c1d2a8a2..000000000 --- a/apps/desktop/src/renderer/components/missions/missionControlViewModel.ts +++ /dev/null @@ -1,525 +0,0 @@ -import type { - GetModelCapabilitiesResult, - MissionDetail, - MissionIntervention, - OrchestratorArtifact, - OrchestratorRunGraph, - OrchestratorWorkerCheckpoint, - PhaseCard, - ValidationEvidenceRequirement, -} from "../../../shared/types"; -import { filterExecutionSteps, isRecord } from "./missionHelpers"; -import { - resolveCloseoutRequirementKeyFromArtifact, - resolveOrchestratorArtifactUri, -} from "../../../shared/proofArtifacts"; - -/** Return trimmed string or null if empty/non-string. Duplicates shared/utils for renderer boundary. */ -function toOptionalString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function coercePhaseCards(mission: MissionDetail | null, runGraph: OrchestratorRunGraph | null): PhaseCard[] { - const runMeta = isRecord(runGraph?.run.metadata) ? runGraph.run.metadata : null; - const runPhaseOverride = Array.isArray(runMeta?.phaseOverride) ? runMeta.phaseOverride as PhaseCard[] : []; - if (runPhaseOverride.length > 0) { - return [...runPhaseOverride].sort((a, b) => a.position - b.position); - } - const missionPhases = Array.isArray(mission?.phaseConfiguration?.selectedPhases) - ? mission.phaseConfiguration.selectedPhases - : []; - return [...missionPhases].sort((a, b) => a.position - b.position); -} - -function normalizeInterventionMeta(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isPlanningQuestionIntervention(intervention: MissionIntervention): boolean { - if (intervention.status !== "open" || intervention.interventionType !== "manual_input") return false; - const metadata = isRecord(intervention.metadata) ? intervention.metadata : null; - const source = normalizeInterventionMeta(metadata?.source); - const reasonCode = normalizeInterventionMeta(metadata?.reasonCode); - if ( - source !== "ask_user" - && source !== "request_user_input" - && reasonCode !== "planner_natural_question" - && reasonCode !== "planner_required_question_missing" - && reasonCode !== "planner_chat_question" - ) { - return false; - } - const phase = normalizeInterventionMeta(metadata?.phase); - const phaseKey = normalizeInterventionMeta(metadata?.phaseKey); - const phaseName = normalizeInterventionMeta(metadata?.phaseName); - const stepType = normalizeInterventionMeta(metadata?.stepType); - const ownerKind = normalizeInterventionMeta(metadata?.questionOwnerKind); - return reasonCode === "planner_natural_question" - || reasonCode === "planner_required_question_missing" - || reasonCode === "planner_chat_question" - || phase === "planning" - || phaseKey === "planning" - || phaseName === "planning" - || stepType === "planning" - || ownerKind === "planner"; -} - -function terminalPhaseSummary(args: { - missionStatus: MissionDetail["status"] | null | undefined; - runStatus: OrchestratorRunGraph["run"]["status"]; -}): string | null { - if (args.missionStatus === "canceled" || args.runStatus === "canceled") { - return "Mission canceled. Rerun to start a fresh run."; - } - if (args.missionStatus === "failed" || args.runStatus === "failed") { - return "Mission failed. Review the feed or rerun after fixing the blocker."; - } - if (args.missionStatus === "completed" || args.runStatus === "succeeded") { - return "Mission completed. Review artifacts or rerun for another pass."; - } - return null; -} - -export type ActivePhaseViewModel = { - currentPhaseKey: string | null; - currentPhaseName: string | null; - phase: PhaseCard | null; - position: number | null; - total: number; - modeLabel: "blocked" | "manual" | "coordinator-driven" | "auto-assisted" | "closed"; - validationRequired: boolean; - validationTier: string; - clarificationLabel: string; - whyActive: string; - exitRequirements: string[]; - blockedLaterWork: string[]; - capabilityWarnings: string[]; -}; - -export function deriveActivePhaseViewModel(args: { - mission: MissionDetail | null; - runGraph: OrchestratorRunGraph | null; - modelCapabilities?: GetModelCapabilitiesResult | null; -}): ActivePhaseViewModel | null { - const { mission, runGraph, modelCapabilities } = args; - if (!runGraph) return null; - const phases = coercePhaseCards(mission, runGraph); - const runMeta = isRecord(runGraph.run.metadata) ? runGraph.run.metadata : null; - const phaseRuntime = isRecord(runMeta?.phaseRuntime) ? runMeta.phaseRuntime : null; - const currentPhaseKey = toOptionalString(phaseRuntime?.currentPhaseKey); - const currentPhaseName = toOptionalString(phaseRuntime?.currentPhaseName); - const phase = phases.find((entry) => - entry.phaseKey === currentPhaseKey - || entry.name === currentPhaseName - ) ?? null; - const terminalSummary = terminalPhaseSummary({ - missionStatus: mission?.status, - runStatus: runGraph.run.status, - }); - - const activePhase = phase ?? phases[0] ?? null; - if (!activePhase) { - return { - currentPhaseKey, - currentPhaseName, - phase: null, - position: null, - total: 0, - modeLabel: terminalSummary ? "closed" : runGraph.run.status === "paused" ? "blocked" : "coordinator-driven", - validationRequired: false, - validationTier: "none", - clarificationLabel: "No phase profile attached", - whyActive: terminalSummary ?? "This run has no visible phase snapshot. The orchestrator is operating without a user-visible phase profile.", - exitRequirements: terminalSummary ? [] : ["Wait for the run to publish a phase snapshot or finish."], - blockedLaterWork: [], - capabilityWarnings: [], - }; - } - - const position = phases.findIndex((entry) => entry.phaseKey === activePhase.phaseKey); - if (terminalSummary) { - return { - currentPhaseKey, - currentPhaseName, - phase: activePhase, - position: position >= 0 ? position + 1 : null, - total: phases.length, - modeLabel: "closed", - validationRequired: activePhase.validationGate.required, - validationTier: activePhase.validationGate.tier, - clarificationLabel: activePhase.askQuestions.enabled - ? `active phase owner may ask${activePhase.askQuestions.maxQuestions == null ? " without a question limit" : `, max ${activePhase.askQuestions.maxQuestions}`}` - : "Ask questions disabled", - whyActive: terminalSummary, - exitRequirements: [], - blockedLaterWork: [], - capabilityWarnings: [], - }; - } - - const openInterventions = mission?.interventions.filter((intervention) => intervention.status === "open") ?? []; - const phaseSteps = filterExecutionSteps(runGraph.steps.filter((step) => { - const metadata = isRecord(step.metadata) ? step.metadata : null; - return metadata?.phaseKey === activePhase.phaseKey || metadata?.phaseName === activePhase.name; - })); - const laterPhaseSteps = filterExecutionSteps(runGraph.steps.filter((step) => { - const metadata = isRecord(step.metadata) ? step.metadata : null; - const stepPosition = Number(metadata?.phasePosition); - return Number.isFinite(stepPosition) && stepPosition > activePhase.position; - })); - const laterBlocked = laterPhaseSteps - .filter((step) => step.status === "blocked" || step.status === "pending" || step.status === "ready") - .slice(0, 4) - .map((step) => `${step.title} (${step.status})`); - - const inFlightCurrentPhase = phaseSteps.filter((step) => step.status === "running"); - const completedCurrentPhase = phaseSteps.filter((step) => - step.status === "succeeded" || step.status === "skipped" || step.status === "canceled" - ); - const remainingCurrentPhaseCount = Math.max(0, phaseSteps.length - completedCurrentPhase.length); - - let modeLabel: ActivePhaseViewModel["modeLabel"] = "auto-assisted"; - if (runGraph.run.status === "paused" || openInterventions.length > 0) { - modeLabel = "blocked"; - } else if (activePhase.askQuestions.enabled) { - modeLabel = "manual"; - } else if (activePhase.phaseKey === "planning" || activePhase.phaseKey === "validation") { - modeLabel = "coordinator-driven"; - } - - const whyBits: string[] = []; - if (inFlightCurrentPhase.length > 0) { - whyBits.push(`${inFlightCurrentPhase.length} step${inFlightCurrentPhase.length === 1 ? " is" : "s are"} active in this phase.`); - } else if (phaseSteps.length > 0 && remainingCurrentPhaseCount > 0) { - whyBits.push("This phase still has non-terminal work that must finish before advancement."); - } else if (runGraph.run.status === "paused") { - whyBits.push("The run is paused, so this phase remains active until the mission resumes."); - } else { - whyBits.push("This is the latest runtime phase snapshot recorded on the run."); - } - - const exitRequirements: string[] = []; - if (activePhase.phaseKey === "planning") { - if (phaseSteps.length > 0 && remainingCurrentPhaseCount > 0) { - exitRequirements.push(`Wait for the planning worker to finish and review ${remainingCurrentPhaseCount} remaining planning step(s).`); - } else if (phaseSteps.length > 0) { - exitRequirements.push("Planning worker output is ready for coordinator review."); - } else { - exitRequirements.push("The coordinator must either ask planning questions or start the planning worker."); - } - const openPlanningQuestions = openInterventions.filter(isPlanningQuestionIntervention); - if (openPlanningQuestions.length > 0) { - exitRequirements.push(`Answer ${openPlanningQuestions.length} open planning question(s).`); - } - if (activePhase.requiresApproval) { - exitRequirements.push("Review the generated plan and explicitly approve the planning phase before ADE moves into Development."); - } else { - exitRequirements.push("Once the planner succeeds, ADE can move into Development immediately."); - } - } else if (activePhase.phaseKey === "validation") { - if (phaseSteps.length > 0 && remainingCurrentPhaseCount > 0) { - exitRequirements.push(`Finish the remaining validation step${remainingCurrentPhaseCount === 1 ? "" : "s"}.`); - } else if (phaseSteps.length > 0) { - exitRequirements.push("All executable validation steps are terminal. ADE can complete after coordinator review."); - } else { - exitRequirements.push("Validation is active. ADE will finish the final validation protocol before completion."); - } - if (openInterventions.length > 0) { - exitRequirements.push(`Resolve ${openInterventions.length} open intervention(s).`); - } - } else { - if (phaseSteps.length > 0 && remainingCurrentPhaseCount > 0) { - exitRequirements.push(`Finish or explicitly disposition ${remainingCurrentPhaseCount} remaining step(s) in ${activePhase.name}.`); - } else if (phaseSteps.length > 0) { - exitRequirements.push(`All executable ${activePhase.name} steps are terminal. ADE can advance after coordinator review.`); - } else { - exitRequirements.push(`The coordinator must finish the ${activePhase.name} phase protocol before advancing.`); - } - if (activePhase.validationGate.required) { - exitRequirements.push("A required validation step still needs to pass before ADE can move on."); - } - if (openInterventions.length > 0) { - exitRequirements.push(`Resolve ${openInterventions.length} open intervention(s).`); - } - } - - const capabilityWarnings: string[] = []; - const HARD_EVIDENCE_KINDS = new Set(["screenshot", "browser_verification", "video_recording", "browser_trace"]); - const hardEvidence = (activePhase.validationGate.evidenceRequirements ?? []).filter((e) => HARD_EVIDENCE_KINDS.has(e)); - if (hardEvidence.length > 0) { - const modelId = activePhase.model.modelId; - const capabilityProfile = modelCapabilities?.profiles.find((p) => p.modelId === modelId) ?? null; - if (!capabilityProfile) { - capabilityWarnings.push(`Evidence requires ${hardEvidence.join(", ")}, but this run has no persisted capability profile for ${modelId}. Validate runtime support before relying on the gate.`); - } else { - const capabilityText = `${capabilityProfile.strengths.join(" ")} ${capabilityProfile.weaknesses.join(" ")}`.toLowerCase(); - const likelyBrowserCapable = capabilityText.includes("browser") || capabilityText.includes("computer use") || capabilityText.includes("verification"); - if (!likelyBrowserCapable) { - capabilityWarnings.push(`Evidence requires ${hardEvidence.join(", ")}, but ${capabilityProfile.displayName} does not advertise browser/screenshot capability in the current runtime profile.`); - } - } - } - if (activePhase.validationGate.required && activePhase.validationGate.capabilityFallback === "warn") { - capabilityWarnings.push("This phase is configured to warn rather than hard-block when evidence capability is missing."); - } - - return { - currentPhaseKey, - currentPhaseName, - phase: activePhase, - position: position >= 0 ? position + 1 : null, - total: phases.length, - modeLabel, - validationRequired: activePhase.validationGate.required, - validationTier: activePhase.validationGate.tier, - clarificationLabel: activePhase.askQuestions.enabled - ? `active phase owner may ask${activePhase.askQuestions.maxQuestions == null ? " without a question limit" : `, max ${activePhase.askQuestions.maxQuestions}`}` - : "Ask questions disabled", - whyActive: whyBits.join(" "), - exitRequirements, - blockedLaterWork: laterBlocked, - capabilityWarnings, - }; -} - -export type MissionArtifactRecord = { - id: string; - source: "mission" | "orchestrator" | "checkpoint"; - title: string; - description: string | null; - artifactType: string; - stepId: string | null; - stepTitle: string | null; - workerId: string | null; - workerLabel: string | null; - phaseKey: string | null; - phaseName: string | null; - uri: string | null; - textContent: string | null; - declared: boolean; - missingExpectedEvidence: boolean; - createdAt: string; -}; - -export type GroupedMissionArtifacts = { - all: MissionArtifactRecord[]; - byPhase: Array<{ key: string; label: string; items: MissionArtifactRecord[] }>; - byStep: Array<{ key: string; label: string; items: MissionArtifactRecord[] }>; - byWorker: Array<{ key: string; label: string; items: MissionArtifactRecord[] }>; - byType: Array<{ key: string; label: string; items: MissionArtifactRecord[] }>; - expectedEvidence: ValidationEvidenceRequirement[]; -}; - -function normalizeMissionArtifacts(mission: MissionDetail | null): MissionArtifactRecord[] { - const artifacts = mission?.artifacts ?? []; - return artifacts.map((artifact): MissionArtifactRecord => ({ - id: artifact.id, - source: "mission", - title: artifact.title, - description: artifact.description, - artifactType: resolveCloseoutRequirementKeyFromArtifact({ - artifactType: artifact.artifactType, - metadata: artifact.metadata, - }) ?? artifact.artifactType, - stepId: toOptionalString(artifact.metadata?.stepId), - stepTitle: toOptionalString(artifact.metadata?.stepTitle), - workerId: toOptionalString(artifact.metadata?.attemptId) - ?? toOptionalString(artifact.metadata?.workerId) - ?? toOptionalString(artifact.metadata?.workerName) - ?? toOptionalString(artifact.createdBy), - workerLabel: toOptionalString(artifact.metadata?.workerName) - ?? toOptionalString(artifact.metadata?.workerLabel) - ?? toOptionalString(artifact.createdBy), - phaseKey: toOptionalString(artifact.metadata?.phaseKey), - phaseName: toOptionalString(artifact.metadata?.phaseName), - uri: artifact.uri, - textContent: artifact.description, - declared: true, - missingExpectedEvidence: false, - createdAt: artifact.createdAt, - })); -} - -type StepMap = Map<string, OrchestratorRunGraph["steps"][number]>; - -function buildStepMap(runGraph: OrchestratorRunGraph | null): StepMap { - return new Map((runGraph?.steps ?? []).map((step) => [step.id, step] as const)); -} - -type AttemptMap = Map<string, OrchestratorRunGraph["attempts"][number]>; - -function buildAttemptMap(runGraph: OrchestratorRunGraph | null): AttemptMap { - return new Map((runGraph?.attempts ?? []).map((attempt) => [attempt.id, attempt] as const)); -} - -function shortArtifactWorkerId(id: string): string { - return id.length > 8 ? id.slice(0, 8) : id; -} - -function resolveArtifactWorkerLabel(args: { - artifactMeta?: Record<string, unknown> | null; - stepMeta?: Record<string, unknown> | null; - stepTitle?: string | null; - attemptId?: string | null; - attemptOwnerId?: string | null; -}): string | null { - return toOptionalString(args.artifactMeta?.workerName) - ?? toOptionalString(args.artifactMeta?.workerLabel) - ?? toOptionalString(args.stepMeta?.workerName) - ?? toOptionalString(args.stepMeta?.workerLabel) - ?? toOptionalString(args.attemptOwnerId) - ?? args.stepTitle - ?? (args.attemptId ? `Worker ${shortArtifactWorkerId(args.attemptId)}` : null); -} - -function normalizeOrchestratorArtifacts(stepById: StepMap, artifacts: OrchestratorArtifact[]): MissionArtifactRecord[] { - return artifacts.map((artifact) => { - const step = artifact.stepId ? stepById.get(artifact.stepId) ?? null : null; - const metadata = isRecord(step?.metadata) ? step.metadata : null; - const artifactMeta = isRecord(artifact.metadata) ? artifact.metadata : null; - return { - id: artifact.id, - source: "orchestrator", - title: toOptionalString(artifactMeta?.title) ?? artifact.artifactKey.replace(/_/g, " "), - description: toOptionalString(artifactMeta?.summary) ?? toOptionalString(artifactMeta?.description), - artifactType: resolveCloseoutRequirementKeyFromArtifact({ - artifactKey: artifact.artifactKey, - kind: artifact.kind, - metadata: artifact.metadata, - }) ?? artifact.kind, - stepId: artifact.stepId, - stepTitle: step?.title ?? null, - workerId: artifact.attemptId ?? artifact.stepId ?? null, - workerLabel: resolveArtifactWorkerLabel({ - artifactMeta, - stepMeta: metadata, - stepTitle: step?.title ?? null, - attemptId: artifact.attemptId, - }), - phaseKey: toOptionalString(metadata?.phaseKey), - phaseName: toOptionalString(metadata?.phaseName), - uri: resolveOrchestratorArtifactUri({ - kind: artifact.kind, - value: artifact.value, - metadata: artifact.metadata, - }), - textContent: artifact.kind === "custom" || artifact.kind === "checkpoint" || artifact.kind === "test_report" ? artifact.value : null, - declared: artifact.declared, - missingExpectedEvidence: false, - createdAt: artifact.createdAt, - }; - }); -} - -function normalizeCheckpoints(stepById: StepMap, attemptById: AttemptMap, checkpoints: OrchestratorWorkerCheckpoint[]): MissionArtifactRecord[] { - return checkpoints.map((checkpoint) => { - const step = stepById.get(checkpoint.stepId) ?? null; - const metadata = isRecord(step?.metadata) ? step.metadata : null; - const attempt = attemptById.get(checkpoint.attemptId) ?? null; - const attemptMetadata = isRecord(attempt?.metadata) ? attempt.metadata : null; - return { - id: checkpoint.id, - source: "checkpoint", - title: `Checkpoint: ${checkpoint.stepKey}`, - description: checkpoint.filePath, - artifactType: "checkpoint", - stepId: checkpoint.stepId, - stepTitle: step?.title ?? null, - workerId: checkpoint.attemptId ?? checkpoint.stepId ?? null, - workerLabel: resolveArtifactWorkerLabel({ - stepMeta: metadata, - stepTitle: step?.title ?? checkpoint.stepKey, - attemptId: checkpoint.attemptId, - attemptOwnerId: toOptionalString(attemptMetadata?.ownerId), - }), - phaseKey: toOptionalString(metadata?.phaseKey), - phaseName: toOptionalString(metadata?.phaseName), - uri: checkpoint.filePath, - textContent: checkpoint.content, - declared: true, - missingExpectedEvidence: false, - createdAt: checkpoint.updatedAt, - }; - }); -} - -function buildGroups(items: MissionArtifactRecord[], keyFn: (item: MissionArtifactRecord) => string, labelFn: (item: MissionArtifactRecord) => string) { - const map = new Map<string, { key: string; label: string; items: MissionArtifactRecord[] }>(); - for (const item of items) { - const key = keyFn(item); - const label = labelFn(item); - const bucket = map.get(key) ?? { key, label, items: [] }; - bucket.items.push(item); - map.set(key, bucket); - } - return [...map.values()].sort((a, b) => b.items.length - a.items.length || a.label.localeCompare(b.label)); -} - -export function buildMissionArtifactGroups(args: { - mission: MissionDetail | null; - runGraph: OrchestratorRunGraph | null; - orchestratorArtifacts: OrchestratorArtifact[]; - checkpoints: OrchestratorWorkerCheckpoint[]; -}): GroupedMissionArtifacts { - const stepById = buildStepMap(args.runGraph); - const attemptById = buildAttemptMap(args.runGraph); - const missionArtifacts = normalizeMissionArtifacts(args.mission); - const orchestratorArtifacts = normalizeOrchestratorArtifacts(stepById, args.orchestratorArtifacts); - const checkpointArtifacts = normalizeCheckpoints(stepById, attemptById, args.checkpoints); - const all = [...missionArtifacts, ...orchestratorArtifacts, ...checkpointArtifacts] - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); - - const phaseCards = coercePhaseCards(args.mission, args.runGraph); - const expectedEvidence = [...new Set( - phaseCards.flatMap((phase) => phase.validationGate.evidenceRequirements ?? []) - )]; - const presentEvidence = new Set(all.map((artifact) => artifact.artifactType)); - for (const requirement of expectedEvidence) { - if (presentEvidence.has(requirement)) continue; - all.push({ - id: `missing:${requirement}`, - source: "orchestrator", - title: `Expected evidence missing: ${requirement.replace(/_/g, " ")}`, - description: "Configured as required evidence, but no artifact has been attached yet.", - artifactType: requirement, - stepId: null, - stepTitle: null, - workerId: null, - workerLabel: null, - phaseKey: null, - phaseName: null, - uri: null, - textContent: null, - declared: true, - missingExpectedEvidence: true, - createdAt: new Date(0).toISOString(), - }); - } - - return { - all, - byPhase: buildGroups( - all, - (item) => item.phaseKey ?? "unassigned", - (item) => item.phaseName ?? "Unassigned phase", - ), - byStep: buildGroups( - all, - (item) => item.stepId ?? "mission", - (item) => item.stepTitle ?? "Mission-level artifacts", - ), - byWorker: buildGroups( - all, - (item) => item.workerId ?? item.stepId ?? "mission", - (item) => item.workerLabel ?? item.stepTitle ?? "Mission-level artifacts", - ), - byType: buildGroups( - all, - (item) => item.artifactType, - (item) => item.artifactType.replace(/_/g, " "), - ), - expectedEvidence, - }; -} diff --git a/apps/desktop/src/renderer/components/missions/missionCreateDialogStore.ts b/apps/desktop/src/renderer/components/missions/missionCreateDialogStore.ts deleted file mode 100644 index 383c3dffd..000000000 --- a/apps/desktop/src/renderer/components/missions/missionCreateDialogStore.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { create } from "zustand"; - -type MissionCreateDialogStore = { - open: boolean; - resetVersion: number; -}; - -export const useMissionCreateDialogStore = create<MissionCreateDialogStore>(() => ({ - open: false, - resetVersion: 0, -})); - -export function openMissionCreateDialog(): void { - useMissionCreateDialogStore.setState({ open: true }); -} - -export function closeMissionCreateDialog(): void { - useMissionCreateDialogStore.setState({ open: false }); -} - -export function resetMissionCreateDialog(): void { - useMissionCreateDialogStore.setState((state) => ({ open: false, resetVersion: state.resetVersion + 1 })); -} diff --git a/apps/desktop/src/renderer/components/missions/missionDialogDataCache.ts b/apps/desktop/src/renderer/components/missions/missionDialogDataCache.ts deleted file mode 100644 index 5b7be0530..000000000 --- a/apps/desktop/src/renderer/components/missions/missionDialogDataCache.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { PhaseCard, PhaseProfile } from "../../../shared/types"; - -const MISSION_DIALOG_DATA_TTL_MS = 5 * 60_000; - -type CacheEntry<T> = { - value: T; - cachedAt: number; -}; - -let phaseProfilesEntry: CacheEntry<PhaseProfile[]> | null = null; -let phaseItemsEntry: CacheEntry<PhaseCard[]> | null = null; - -function isFresh(entry: CacheEntry<unknown> | null): boolean { - return entry != null && Date.now() - entry.cachedAt < MISSION_DIALOG_DATA_TTL_MS; -} - -export function getCachedPhaseProfiles(): PhaseProfile[] | null { - return phaseProfilesEntry?.value ?? null; -} - -export function hasFreshPhaseProfiles(): boolean { - return isFresh(phaseProfilesEntry); -} - -export function setCachedPhaseProfiles(value: PhaseProfile[]): void { - phaseProfilesEntry = { value, cachedAt: Date.now() }; -} - -export function getCachedPhaseItems(): PhaseCard[] | null { - return phaseItemsEntry?.value ?? null; -} - -export function hasFreshPhaseItems(): boolean { - return isFresh(phaseItemsEntry); -} - -export function setCachedPhaseItems(value: PhaseCard[]): void { - phaseItemsEntry = { value, cachedAt: Date.now() }; -} diff --git a/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts b/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts deleted file mode 100644 index 0b850e401..000000000 --- a/apps/desktop/src/renderer/components/missions/missionFeedPresentation.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MissionRunView, MissionRunViewProgressItem } from "../../../shared/types"; -import { buildMissionStateNarrative, prepareMissionFeedItems } from "./missionFeedPresentation"; - -function makeProgressItem(overrides: Partial<MissionRunViewProgressItem> = {}): MissionRunViewProgressItem { - return { - id: "progress-1", - at: "2026-03-11T14:00:00.000Z", - kind: "worker", - title: "Worker result", - detail: "Finished implementation.", - severity: "info", - ...overrides, - }; -} - -function makeRunView(overrides: Partial<MissionRunView> = {}): MissionRunView { - return { - missionId: "mission-1", - runId: "run-1", - lifecycle: { - missionStatus: "in_progress", - runStatus: "active", - displayStatus: "running", - summary: "Mission is actively running.", - startedAt: "2026-03-11T13:30:00.000Z", - completedAt: null, - }, - active: { - phaseKey: "validation", - phaseName: "Validation", - stepId: "step-1", - stepKey: "validate-checkout", - stepTitle: "Review checkout flow", - featureLabel: "checkout", - }, - coordinator: { - available: true, - mode: "continuation_required", - summary: "Coordinator online", - detail: null, - updatedAt: "2026-03-11T14:05:00.000Z", - }, - latestIntervention: null, - haltReason: null, - workers: [], - progressLog: [], - lastMeaningfulProgress: null, - closeoutRequirements: [], - ...overrides, - }; -} - -describe("prepareMissionFeedItems", () => { - it("suppresses repetitive low-signal internal tool chatter and humanizes kept entries", () => { - const items = prepareMissionFeedItems([ - makeProgressItem({ - id: "tool-noise", - at: "2026-03-11T14:00:00.000Z", - kind: "system", - title: "Tool call", - detail: "linear.get_issue", - }), - makeProgressItem({ - id: "meaningful-1", - at: "2026-03-11T14:01:00.000Z", - title: "Worker result", - detail: "Coordinator used posthog.query-run to inspect the conversion drop.", - }), - makeProgressItem({ - id: "meaningful-2", - at: "2026-03-11T14:02:00.000Z", - title: "Worker result", - detail: "Coordinator used posthog.query-run to inspect the conversion drop.", - }), - ]); - - expect(items).toHaveLength(1); - expect(items[0]?.detail).toContain("PostHog Query Run"); - expect(items[0]?.detail).not.toContain("posthog.query-run"); - }); - - it("drops internal lifecycle noise and non-feed items", () => { - const items = prepareMissionFeedItems([ - makeProgressItem({ - id: "timeline-only", - audience: "timeline", - title: "Scheduler tick", - detail: "Internal runtime loop.", - }), - makeProgressItem({ - id: "run-created", - kind: "system", - title: "Run created", - detail: "start_run", - }), - makeProgressItem({ - id: "status-transition", - kind: "system", - title: "Mission update", - detail: "Mission status changed to in_progress.", - }), - makeProgressItem({ - id: "keep", - title: "Worker completed", - detail: "Implemented the mission feed cleanup.", - }), - ]); - - expect(items).toHaveLength(1); - expect(items[0]?.id).toBe("keep"); - }); - - it("drops validation bookkeeping and de-dupes repeated feed entries", () => { - const items = prepareMissionFeedItems([ - makeProgressItem({ - id: "requires-self-validation", - kind: "validation", - title: "Validation signal", - detail: 'Step "integration_phase_closeout" requires self-validation. Review output and call report_validation with verdict pass/fail.', - }), - makeProgressItem({ - id: "state-changed", - kind: "validation", - title: "Validation update", - detail: "Validate: validation-worker validation changed.", - }), - makeProgressItem({ - id: "meaningful-validation", - kind: "validation", - title: "Validation signal", - detail: "Dedicated validation passed. The throwaway implementation is confined to the fake lane.", - }), - makeProgressItem({ - id: "duplicate-validation", - kind: "validation", - title: "Validation signal", - detail: "Dedicated validation passed. The throwaway implementation is confined to the fake lane.", - at: "2026-03-11T14:10:00.000Z", - }), - ]); - - expect(items.map((item) => item.id)).toEqual(["meaningful-validation"]); - }); - - it("preserves readable worker result sections", () => { - const items = prepareMissionFeedItems([ - makeProgressItem({ - id: "worker-result", - title: "Worker result", - detail: [ - "Created the smoke helper.", - "", - "Changed files:", - "- apps/desktop/src/smoke.ts", - "- apps/desktop/src/smoke.test.ts", - "", - "Tests:", - "- npm test -- smoke.test.ts", - "- 3 passed, 0 failed, 0 skipped", - ].join("\n"), - }), - ]); - - expect(items).toHaveLength(1); - expect(items[0]?.detail).toContain("Changed files:\n- apps/desktop/src/smoke.ts"); - expect(items[0]?.detail).toContain("Tests:\n- npm test -- smoke.test.ts"); - }); -}); - -describe("buildMissionStateNarrative", () => { - it("builds a concise major-transition narrative from run view state and latest progress", () => { - const narrative = buildMissionStateNarrative( - makeRunView({ - progressLog: [ - makeProgressItem({ - id: "validation-signal", - kind: "validation", - title: "Validation signal", - detail: "Browser screenshot captured.", - at: "2026-03-11T14:07:00.000Z", - }), - ], - lastMeaningfulProgress: makeProgressItem({ - id: "validation-signal", - kind: "validation", - title: "Validation signal", - detail: "Browser screenshot captured.", - at: "2026-03-11T14:07:00.000Z", - }), - }), - ); - - expect(narrative).toMatchObject({ - title: "Validation in progress", - severity: "info", - at: "2026-03-11T14:07:00.000Z", - }); - expect(narrative?.detail).toContain("Working on Review checkout flow."); - expect(narrative?.detail).toContain("Latest: Validation signal: Browser screenshot captured."); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/missionFeedPresentation.ts b/apps/desktop/src/renderer/components/missions/missionFeedPresentation.ts deleted file mode 100644 index 7cfef5784..000000000 --- a/apps/desktop/src/renderer/components/missions/missionFeedPresentation.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { - MissionRunView, - MissionRunViewProgressItem, - MissionRunViewSeverity, -} from "../../../shared/types"; -import { replaceInternalToolNames } from "../chat/toolPresentation"; -import { looksLikeLowSignalNoise } from "./missionHelpers"; - -export type MissionStateNarrative = { - at: string | null; - title: string; - detail: string; - severity: MissionRunViewSeverity; -}; - -function normalizeWhitespace(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function titleize(value: string): string { - return value - .split(/[_\s-]+/) - .filter(Boolean) - .map((token) => `${token.charAt(0).toUpperCase()}${token.slice(1).toLowerCase()}`) - .join(" ") - .trim(); -} - -function sanitizeMissionFeedText(value: string): string { - return normalizeWhitespace(replaceInternalToolNames(value)); -} - -function sanitizeMissionFeedDisplayText(value: string): string { - return replaceInternalToolNames(value) - .replace(/\r\n?/g, "\n") - .split("\n") - .map((line) => line.replace(/[ \t]+/g, " ").trimEnd()) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -function isDismissiveSteerMessage(value: string): boolean { - return /^(acknowledged\.?|dismissed by user\b.*proceed without action\.?)$/i.test(value.trim()); -} - -function buildMissionFeedSignature(item: MissionRunViewProgressItem): string { - return [ - item.kind, - item.stepKey ?? "", - item.attemptId ?? "", - sanitizeMissionFeedText(item.title).toLowerCase(), - sanitizeMissionFeedText(item.detail).toLowerCase(), - ].join("::"); -} - -function isHumanizedToolOnlyText(value: string): boolean { - const normalized = sanitizeMissionFeedText(value).toLowerCase(); - if (!normalized.length) return true; - return /^(browser|canvas|docs|linear|parallel|posthog|sentry|shadcn\/ui|web|workspace)(?: [a-z0-9]+){0,4}$/.test(normalized); -} - -function isGenericInternalActivityText(value: string): boolean { - const normalized = sanitizeMissionFeedText(value).toLowerCase(); - if (!normalized.length) return true; - if (/^(tool (call|result)|calling tool|reading|searching|listing|inspecting|fetching|opening|loading|thinking|worker update|worker result)$/.test(normalized)) { - return true; - } - if (/^(reading|searching|listing|inspecting|fetching|opening|loading|calling tool)\b/.test(normalized)) { - return true; - } - if (isHumanizedToolOnlyText(normalized)) return true; - return false; -} - -function isLowValueValidationBookkeeping(title: string, detail: string): boolean { - const normalizedTitle = title.toLowerCase(); - const normalizedDetail = detail.toLowerCase(); - if (normalizedTitle !== "validation signal" && normalizedTitle !== "validation update") return false; - if (/^step ".+" requires self-validation\b/.test(normalizedDetail)) return true; - if (/\brequires self-validation\. review output and call report_validation\b/.test(normalizedDetail)) return true; - if (/\bvalidation state changed\.?$/.test(normalizedDetail)) return true; - if (/\bvalidation changed\.?$/.test(normalizedDetail)) return true; - if (/^validate:\s+.+ validation changed\.?$/.test(normalizedDetail)) return true; - if (/^run validation changed\.?$/.test(normalizedDetail)) return true; - return false; -} - -function shouldDisplayMissionFeedItem(item: MissionRunViewProgressItem): boolean { - if (item.audience && item.audience !== "mission_feed") return false; - const title = sanitizeMissionFeedText(item.title); - const detail = sanitizeMissionFeedText(item.detail); - const normalizedTitle = title.toLowerCase(); - const normalizedDetail = detail.toLowerCase(); - - if (!title.length && !detail.length) return false; - if (normalizedTitle === "run created" || normalizedTitle === "run activated") return false; - if ( - normalizedTitle === "mission update" - && ( - normalizedDetail.includes("status changed to") - || normalizedDetail.includes("in_progress") - || normalizedDetail.includes("start_run") - || normalizedDetail.includes("activate_run") - ) - ) { - return false; - } - if (isDismissiveSteerMessage(title) || isDismissiveSteerMessage(detail)) return false; - if (item.kind === "user" && isDismissiveSteerMessage(detail || title)) return false; - if (looksLikeLowSignalNoise(title) && (!detail.length || looksLikeLowSignalNoise(detail))) return false; - if (isLowValueValidationBookkeeping(title, detail)) return false; - - if (item.severity === "info" && item.kind !== "user" && item.kind !== "intervention") { - if (isGenericInternalActivityText(title) && (!detail.length || isGenericInternalActivityText(detail) || looksLikeLowSignalNoise(detail))) { - return false; - } - if (title.toLowerCase() === "worker update" && /(reported progress|reported status|status changed|progress update)/.test(detail.toLowerCase())) { - return false; - } - } - - return true; -} - -function sanitizeMissionFeedItem(item: MissionRunViewProgressItem): MissionRunViewProgressItem { - return { - ...item, - title: sanitizeMissionFeedText(item.title), - detail: sanitizeMissionFeedDisplayText(item.detail), - }; -} - -function pickNarrativeTimestamp(runView: MissionRunView): string | null { - return ( - runView.haltReason?.createdAt - ?? runView.lastMeaningfulProgress?.at - ?? runView.lifecycle.completedAt - ?? runView.coordinator.updatedAt - ?? runView.lifecycle.startedAt - ?? null - ); -} - -function summarizeLatestProgress(progress: MissionRunViewProgressItem | null): string | null { - if (!progress) return null; - const title = sanitizeMissionFeedText(progress.title); - const detail = sanitizeMissionFeedText(progress.detail); - if (!title.length && !detail.length) return null; - if (detail.length && detail.toLowerCase() !== title.toLowerCase()) { - return `${title}: ${detail}`; - } - return title || detail; -} - -export function prepareMissionFeedItems(progressLog: MissionRunViewProgressItem[]): MissionRunViewProgressItem[] { - const chronological = [...progressLog].sort((left, right) => Date.parse(left.at) - Date.parse(right.at)); - const prepared = chronological - .map(sanitizeMissionFeedItem) - .filter(shouldDisplayMissionFeedItem); - - const seen = new Set<string>(); - return prepared.filter((item) => { - const signature = buildMissionFeedSignature(item); - if (seen.has(signature)) return false; - seen.add(signature); - return true; - }); -} - -export function buildMissionStateNarrative(runView: MissionRunView | null): MissionStateNarrative | null { - if (!runView) return null; - - const phaseLabel = sanitizeMissionFeedText(runView.active.phaseName ?? titleize(runView.active.phaseKey ?? "")); - const stepLabel = sanitizeMissionFeedText(runView.active.stepTitle ?? ""); - const latestProgress = summarizeLatestProgress(runView.lastMeaningfulProgress); - const lifecycleSummary = sanitizeMissionFeedText(runView.lifecycle.summary); - - let title = "Mission update"; - let detail = lifecycleSummary; - let severity: MissionRunViewSeverity = "info"; - - switch (runView.lifecycle.displayStatus) { - case "running": - title = phaseLabel.length ? `${phaseLabel} in progress` : "Mission running"; - detail = stepLabel.length ? `Working on ${stepLabel}.` : lifecycleSummary; - break; - case "starting": - title = "Mission starting"; - detail = lifecycleSummary || "Preparing mission runtime and coordinator."; - break; - case "blocked": - title = sanitizeMissionFeedText(runView.haltReason?.title ?? "Mission blocked"); - detail = sanitizeMissionFeedText(runView.haltReason?.detail ?? lifecycleSummary); - severity = runView.haltReason?.severity ?? "warning"; - break; - case "paused": - title = sanitizeMissionFeedText(runView.haltReason?.title ?? "Mission paused"); - detail = sanitizeMissionFeedText(runView.haltReason?.detail ?? lifecycleSummary); - severity = runView.haltReason?.severity ?? "warning"; - break; - case "completed": - title = "Mission completed"; - detail = lifecycleSummary || "The run finished successfully."; - severity = "success"; - break; - case "failed": - title = sanitizeMissionFeedText(runView.haltReason?.title ?? "Mission failed"); - detail = sanitizeMissionFeedText(runView.haltReason?.detail ?? lifecycleSummary); - severity = runView.haltReason?.severity ?? "error"; - break; - case "canceled": - title = "Mission canceled"; - detail = lifecycleSummary || "The run was canceled."; - severity = "warning"; - break; - case "not_started": - title = "Mission ready"; - detail = lifecycleSummary || "Start a run to begin execution."; - break; - } - - if (latestProgress && !detail.toLowerCase().includes(latestProgress.toLowerCase()) && latestProgress.toLowerCase() !== title.toLowerCase()) { - detail = detail.length ? `${detail} Latest: ${latestProgress}` : latestProgress; - } - - return { - at: pickNarrativeTimestamp(runView), - title, - detail, - severity, - }; -} diff --git a/apps/desktop/src/renderer/components/missions/missionHelpers.test.ts b/apps/desktop/src/renderer/components/missions/missionHelpers.test.ts deleted file mode 100644 index ff96d6174..000000000 --- a/apps/desktop/src/renderer/components/missions/missionHelpers.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - compactText, - computeProgress, - formatElapsed, - formatMissionWorkerPresentation, - getAvailableLifecycleActions, - getMissionStatusBadgeConfig, - isMissionVisiblyActive, - looksLikeLowSignalNoise, - narrativeForEvent, -} from "./missionHelpers"; - -describe("narrativeForEvent", () => { - it("uses step keys from timeline detail instead of exposing internal ids", () => { - expect(narrativeForEvent({ - eventType: "step_registered", - reason: "add_steps", - stepId: "e57e9ac4-8efd-4a8b-ad17-24f99d299b10", - detail: { - stepKey: "worker_context-the-original-scenario-fixtures-worker-wa_1778196304920", - }, - })).toBe('Added "context the original scenario fixtures worker wa" to the plan'); - }); - - it("falls back to a generic step label when only an id is available", () => { - expect(narrativeForEvent({ - eventType: "step_status_changed", - reason: "readiness_recomputed", - stepId: "e57e9ac4-8efd-4a8b-ad17-24f99d299b10", - })).toBe("Step update: readiness_recomputed"); - }); - - it("renders steering and replan timeline events with operator context", () => { - expect(narrativeForEvent({ - eventType: "coordinator_steering", - reason: "operator directive", - detail: { - directive: "Prioritize the schedule-risk console and defer broad dashboard polish.", - targetStepKey: "implement-risk-console", - }, - })).toBe("User steering for implement risk console: Prioritize the schedule-risk console and defer broad dashboard polish."); - - expect(narrativeForEvent({ - eventType: "plan_revised", - reason: "revise_plan", - detail: { - reason: "Scope changed after operator review.", - }, - })).toBe("Plan revised: Scope changed after operator review."); - }); -}); - -describe("computeProgress", () => { - it("returns zeros for empty steps", () => { - expect(computeProgress([])).toEqual({ completed: 0, total: 0, pct: 0 }); - }); - - it("counts succeeded and skipped as completed", () => { - const steps = [ - { status: "succeeded" as const, metadata: null }, - { status: "skipped" as const, metadata: null }, - { status: "running" as const, metadata: null }, - ]; - const result = computeProgress(steps); - expect(result.completed).toBe(2); - expect(result.total).toBe(3); - expect(result.pct).toBe(67); - }); - - it("excludes superseded steps from total", () => { - const steps = [ - { status: "succeeded" as const, metadata: null }, - { status: "superseded" as const, metadata: null }, - { status: "running" as const, metadata: null }, - ]; - const result = computeProgress(steps); - expect(result.total).toBe(2); - expect(result.completed).toBe(1); - expect(result.pct).toBe(50); - }); - - it("excludes display-only task steps", () => { - const steps = [ - { status: "succeeded" as const, metadata: { isTask: true } }, - { status: "succeeded" as const, metadata: null }, - { status: "running" as const, metadata: null }, - ]; - const result = computeProgress(steps); - expect(result.total).toBe(2); - expect(result.completed).toBe(1); - }); - - it("excludes legacy task shells identified by stepType", () => { - const steps = [ - { status: "pending" as const, metadata: { stepType: "task" } }, - { status: "succeeded" as const, metadata: { stepType: "implementation", spawnedByCoordinator: true } }, - ]; - const result = computeProgress(steps); - expect(result.total).toBe(1); - expect(result.completed).toBe(1); - }); - - it("can include user-facing planned task shells while still excluding system trackers", () => { - const steps = [ - { status: "succeeded" as const, metadata: { displayOnlyTask: true, systemManaged: true, plannerLaunchTracker: true } }, - { status: "succeeded" as const, metadata: { stepType: "planning" } }, - { status: "succeeded" as const, metadata: { stepType: "implementation" } }, - { status: "pending" as const, metadata: { stepType: "task", displayOnlyTask: true } }, - ]; - const result = computeProgress(steps, { includeDisplayOnlyTaskShells: true }); - expect(result.total).toBe(3); - expect(result.completed).toBe(2); - expect(result.pct).toBe(67); - }); - - it("excludes retry variants", () => { - const steps = [ - { status: "failed" as const, metadata: null }, - { status: "running" as const, metadata: { retryOf: "step-1" } }, - ]; - const result = computeProgress(steps); - expect(result.total).toBe(1); - }); - - it("counts canceled as completed", () => { - const steps = [ - { status: "canceled" as const, metadata: null }, - ]; - const result = computeProgress(steps); - expect(result.completed).toBe(1); - expect(result.pct).toBe(100); - }); -}); - -describe("getAvailableLifecycleActions", () => { - it("returns stop_run and cancel_mission for active run in non-terminal mission", () => { - const actions = getAvailableLifecycleActions("in_progress", "active"); - expect(actions).toContain("stop_run"); - expect(actions).toContain("cancel_mission"); - expect(actions).not.toContain("archive_mission"); - }); - - it("returns only cancel_mission when no active run but non-terminal", () => { - const actions = getAvailableLifecycleActions("planning", null); - expect(actions).not.toContain("stop_run"); - expect(actions).toContain("cancel_mission"); - expect(actions).not.toContain("archive_mission"); - }); - - it("returns archive_mission for terminal missions", () => { - const actions = getAvailableLifecycleActions("completed", "succeeded"); - expect(actions).toContain("archive_mission"); - expect(actions).not.toContain("cancel_mission"); - }); - - it("returns archive_mission for failed missions", () => { - const actions = getAvailableLifecycleActions("failed", "failed"); - expect(actions).toContain("archive_mission"); - }); - - it("returns archive_mission for canceled missions", () => { - const actions = getAvailableLifecycleActions("canceled", "canceled"); - expect(actions).toContain("archive_mission"); - }); -}); - -describe("formatElapsed", () => { - it("returns '--' for null startedAt", () => { - expect(formatElapsed(null)).toBe("--"); - }); - - it("formats seconds", () => { - const start = new Date(Date.now() - 30_000).toISOString(); - const end = new Date().toISOString(); - expect(formatElapsed(start, end)).toBe("30s"); - }); - - it("formats minutes and seconds", () => { - const start = new Date(Date.now() - 125_000).toISOString(); - const end = new Date().toISOString(); - expect(formatElapsed(start, end)).toBe("2m 5s"); - }); - - it("formats hours and minutes", () => { - const start = new Date(Date.now() - 3_900_000).toISOString(); - const end = new Date().toISOString(); - expect(formatElapsed(start, end)).toBe("1h 5m"); - }); - - it("uses current time when endedAt is null", () => { - const start = new Date(Date.now() - 5_000).toISOString(); - const result = formatElapsed(start); - expect(result).toMatch(/^\d+s$/); - }); -}); - -describe("looksLikeLowSignalNoise", () => { - it("returns true for empty strings", () => { - expect(looksLikeLowSignalNoise("")).toBe(true); - expect(looksLikeLowSignalNoise(" ")).toBe(true); - }); - - it("returns true for streaming placeholders", () => { - expect(looksLikeLowSignalNoise("streaming...")).toBe(true); - expect(looksLikeLowSignalNoise("streaming")).toBe(true); - expect(looksLikeLowSignalNoise("Streaming...")).toBe(true); - }); - - it("returns true for usage placeholders", () => { - expect(looksLikeLowSignalNoise("usage")).toBe(true); - expect(looksLikeLowSignalNoise("Usage")).toBe(true); - }); - - it("returns false for substantive content", () => { - expect(looksLikeLowSignalNoise("I have implemented the auth module.")).toBe(false); - expect(looksLikeLowSignalNoise("The build failed due to a type error in utils.ts")).toBe(false); - }); - - it("returns true for short non-word tokens", () => { - expect(looksLikeLowSignalNoise("abc")).toBe(true); - }); - - it("returns true for permission-like strings", () => { - expect(looksLikeLowSignalNoise("-rwxr-xr-x")).toBe(true); - }); -}); - -describe("compactText", () => { - it("returns empty for empty input", () => { - expect(compactText("")).toBe(""); - expect(compactText(" ")).toBe(""); - }); - - it("normalizes whitespace", () => { - expect(compactText("hello world")).toBe("hello world"); - }); - - it("truncates to maxChars with ellipsis", () => { - const result = compactText("This is a fairly long sentence that needs truncation.", 20); - expect(result.length).toBeLessThanOrEqual(22); - expect(result).toContain("..."); - }); - - it("does not truncate short strings", () => { - expect(compactText("short")).toBe("short"); - }); -}); - -describe("formatMissionWorkerPresentation", () => { - it("returns generic worker presentation for empty input", () => { - const result = formatMissionWorkerPresentation({}); - expect(result.label).toBe("Worker"); - expect(result.phaseLabel).toBeNull(); - }); - - it("extracts phase from planner-prefixed title", () => { - const result = formatMissionWorkerPresentation({ title: "planner: analyze requirements" }); - expect(result.phaseLabel).toBe("Planning"); - expect(result.label).toContain("analyze"); - }); - - it("extracts phase from dev-prefixed title", () => { - const result = formatMissionWorkerPresentation({ title: "developer: build auth" }); - expect(result.phaseLabel).toBe("Development"); - }); - - it("extracts phase from test-prefixed title", () => { - const result = formatMissionWorkerPresentation({ title: "tester: run unit tests" }); - expect(result.phaseLabel).toBe("Testing"); - }); - - it("extracts phase from validator-prefixed title", () => { - const result = formatMissionWorkerPresentation({ title: "validator: check contracts" }); - expect(result.phaseLabel).toBe("Validation"); - }); - - it("uses stepKey as fallback when title is empty", () => { - const result = formatMissionWorkerPresentation({ title: null, stepKey: "review-code" }); - expect(result.phaseLabel).toBe("Review"); - }); - - it("strips Worker: prefix from titles", () => { - const result = formatMissionWorkerPresentation({ title: "Worker: build feature" }); - expect(result.label).not.toContain("Worker:"); - expect(result.label).toContain("build"); - }); - - it("collapses generated planning-worker prompts to a readable label", () => { - const result = formatMissionWorkerPresentation({ - title: "you-are-the-read-only-planning-worker-for-ade-mi", - stepKey: "worker_you-are-the-read-only-planning-worker-for-ade-mi_1778163691752", - }); - expect(result.label).toBe("Planning worker"); - expect(result.phaseLabel).toBe("Planning"); - }); -}); - -describe("getMissionStatusBadgeConfig", () => { - it("prefers the run-view lifecycle status for selected mission badges", () => { - const config = getMissionStatusBadgeConfig("in_progress", "blocked"); - expect(config.label).toBe("Blocked"); - expect(config.color).toBe("#F59E0B"); - }); - - it("falls back to raw mission status when no run-view status is available", () => { - const config = getMissionStatusBadgeConfig("in_progress", null); - expect(config.label).toBe("Running"); - expect(config.color).toBe("#22C55E"); - }); - - it("lets terminal mission status override a stale running run-view status", () => { - const config = getMissionStatusBadgeConfig("completed", "running"); - expect(config.label).toBe("Done"); - }); -}); - -describe("isMissionVisiblyActive", () => { - it("does not pulse a raw running mission once the run-view is blocked", () => { - expect(isMissionVisiblyActive("in_progress", "blocked")).toBe(false); - }); - - it("keeps active styling for run-view starting and running states", () => { - expect(isMissionVisiblyActive("queued", "starting")).toBe(true); - expect(isMissionVisiblyActive("queued", "running")).toBe(true); - }); - - it("does not keep active styling when a terminal mission has a stale active run-view", () => { - expect(isMissionVisiblyActive("completed", "running")).toBe(false); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/missionHelpers.ts b/apps/desktop/src/renderer/components/missions/missionHelpers.ts deleted file mode 100644 index 871703cdb..000000000 --- a/apps/desktop/src/renderer/components/missions/missionHelpers.ts +++ /dev/null @@ -1,1039 +0,0 @@ -import React from "react"; -import { - Rocket, - CircleHalf, - Robot, - Shield, - Lightning, - GitBranch, - ChatCircle, - Pulse, -} from "@phosphor-icons/react"; -import type { - MissionCliPermissionMode, - MissionCliSandboxPermissions, - MissionInProcessPermissionMode, - MissionPermissionConfig, - ModelConfig, - MissionPriority, - MissionStatus, - MissionRunViewDisplayStatus, - OrchestratorAttempt, - OrchestratorChatMessage, - OrchestratorClaim, - OrchestratorStep, - MissionMetricToggle, - MissionMetricSample, - OrchestratorStepStatus, - SmartBudgetConfig, - MissionIntervention, -} from "../../../shared/types"; -import { getDefaultModelDescriptor, getModelById } from "../../../shared/modelRegistry"; -import { COLORS } from "../lanes/laneDesignTokens"; - -/* ════════════════════ STATUS CONFIG (VAL-UX-007) ════════════════════ */ - -/** - * Single canonical status configuration — color / label / icon per MissionStatus. - * Every component that needs status styling MUST import from here. (VAL-UX-007) - */ -export const STATUS_CONFIG: Record<MissionStatus, { - color: string; - label: string; - icon: string; - background: string; - border: string; -}> = { - queued: { color: "#71717A", label: "Queued", icon: "⏳", background: "#71717A18", border: "1px solid #71717A30" }, - planning: { color: "#3B82F6", label: "Planning", icon: "📋", background: "#3B82F618", border: "1px solid #3B82F630" }, - in_progress: { color: "#22C55E", label: "Running", icon: "▶", background: "#22C55E18", border: "1px solid #22C55E30" }, - intervention_required: { color: "#F59E0B", label: "Action", icon: "⚠", background: "#F59E0B18", border: "1px solid #F59E0B30" }, - completed: { color: "#22C55E", label: "Done", icon: "✓", background: "#22C55E18", border: "1px solid #22C55E30" }, - failed: { color: "#EF4444", label: "Failed", icon: "✗", background: "#EF444418", border: "1px solid #EF444430" }, - canceled: { color: "#71717A", label: "Canceled", icon: "⊘", background: "#71717A18", border: "1px solid #71717A30" }, -}; - -export const RUN_VIEW_STATUS_CONFIG: Record<MissionRunViewDisplayStatus, { - color: string; - label: string; - icon: string; - background: string; - border: string; -}> = { - not_started: { color: "#71717A", label: "Not started", icon: "", background: "#71717A18", border: "1px solid #71717A30" }, - starting: { color: "#A78BFA", label: "Starting", icon: "", background: "#A78BFA18", border: "1px solid #A78BFA30" }, - running: { color: "#22C55E", label: "Running", icon: "", background: "#22C55E18", border: "1px solid #22C55E30" }, - paused: { color: "#F59E0B", label: "Paused", icon: "", background: "#F59E0B18", border: "1px solid #F59E0B30" }, - blocked: { color: "#F59E0B", label: "Blocked", icon: "", background: "#F59E0B18", border: "1px solid #F59E0B30" }, - completed: { color: "#22C55E", label: "Done", icon: "", background: "#22C55E18", border: "1px solid #22C55E30" }, - failed: { color: "#EF4444", label: "Failed", icon: "", background: "#EF444418", border: "1px solid #EF444430" }, - canceled: { color: "#71717A", label: "Canceled", icon: "", background: "#71717A18", border: "1px solid #71717A30" }, -}; - -export function getMissionStatusBadgeConfig( - missionStatus: MissionStatus, - displayStatus?: MissionRunViewDisplayStatus | null, -) { - if (missionStatus === "completed") return STATUS_CONFIG.completed; - if (missionStatus === "failed") return STATUS_CONFIG.failed; - if (missionStatus === "canceled") return STATUS_CONFIG.canceled; - return displayStatus ? RUN_VIEW_STATUS_CONFIG[displayStatus] : STATUS_CONFIG[missionStatus]; -} - -export function isMissionVisiblyActive( - missionStatus: MissionStatus, - displayStatus?: MissionRunViewDisplayStatus | null, -): boolean { - if (missionStatus === "completed" || missionStatus === "failed" || missionStatus === "canceled") { - return false; - } - if (displayStatus) return displayStatus === "running" || displayStatus === "starting"; - return missionStatus === "in_progress" || missionStatus === "planning"; -} - -export const PRIORITY_STYLES: Record<MissionPriority, { background: string; color: string; border: string }> = { - urgent: { background: "#EF444418", color: "#EF4444", border: "1px solid #EF444430" }, - high: { background: "#F59E0B18", color: "#F59E0B", border: "1px solid #F59E0B30" }, - normal: { background: "#3B82F618", color: "#3B82F6", border: "1px solid #3B82F630" }, - low: { background: "#71717A18", color: "#71717A", border: "1px solid #71717A30" }, -}; - -export const STEP_STATUS_HEX: Record<string, string> = { - pending: "#3B82F6", - ready: "#3B82F6", - running: "#A78BFA", - succeeded: "#22C55E", - failed: "#EF4444", - skipped: "#71717A", - superseded: "#F59E0B", - blocked: "#F59E0B", - canceled: "#71717A", -}; - -export const EXECUTOR_BADGE_HEX: Record<string, string> = { - opencode: "#6366F1", - claude: "#A78BFA", - codex: "#22C55E", - cursor: "#0EA5E9", - shell: "#F59E0B", - manual: "#3B82F6", -}; - -import { TERMINAL_MISSION_STATUSES } from "../../../shared/types"; -export { TERMINAL_MISSION_STATUSES }; - -export const NOISY_EVENT_TYPES = new Set([ - "scheduler_tick", - "claim_heartbeat", - "autopilot_parallelism_cap_adjusted", - "context_snapshot_created", - "context_pack_v2_metrics", - "executor_session_attached", - "startup_verification_warning", - "step_metadata_updated", - "step_dependencies_resolved", - "tick", - "dynamic_cap", -]); - -export const STALE_HEARTBEAT_THRESHOLD_MINUTES = 3; - -export const METRIC_TOGGLE_ORDER: MissionMetricToggle[] = [ - "planning", - "implementation", - "testing", - "validation", - "code_review", - "test_review", - "integration", - "cost", - "tokens", - "retries", - "claims", - "context_pressure", - "interventions" -]; - -export const METRIC_TOGGLE_LABELS: Record<MissionMetricToggle, string> = { - planning: "Planning", - implementation: "Implementation", - testing: "Testing", - validation: "Validation", - code_review: "Code Review", - test_review: "Test Review", - integration: "Integration", - cost: "Cost", - tokens: "Tokens", - retries: "Retries", - claims: "Claims", - context_pressure: "Context Pressure", - interventions: "Interventions" -}; - -export const WORKER_STATUS_HEX: Record<string, string> = { - spawned: "#3B82F6", - initializing: "#6366F1", - working: "#A78BFA", - waiting_input: "#F59E0B", - idle: "#3B82F6", - completed: "#22C55E", - failed: "#EF4444", - disposed: "#71717A", -}; - -export type WorkspaceTab = "overview" | "plan" | "chat" | "artifacts" | "history"; -export type MissionListViewMode = "list" | "board"; -export type PlannerProvider = "auto" | "claude" | "codex"; -export type TeammatePlanMode = "auto" | "off" | "required"; -export type SteeringEntry = { directive: string; appliedAt: string }; - -export const MISSION_BOARD_COLUMNS: Array<{ key: MissionStatus; label: string; hex: string }> = [ - { key: "queued", label: "QUEUED", hex: "#71717A" }, - { key: "planning", label: "PLANNING", hex: "#3B82F6" }, - { key: "in_progress", label: "RUNNING", hex: "#22C55E" }, - { key: "intervention_required", label: "ACTION", hex: "#F59E0B" }, - { key: "completed", label: "DONE", hex: "#22C55E" }, - { key: "failed", label: "FAILED", hex: "#EF4444" }, - { key: "canceled", label: "CANCELED", hex: "#71717A" }, -]; - -export type MissionSettingsDraft = { - defaultOrchestratorModel: ModelConfig; - teammatePlanMode: TeammatePlanMode; - permissionConfig: MissionPermissionConfig; - /** @deprecated kept for backward-compat read/write */ - cliMode: MissionCliPermissionMode; - /** @deprecated kept for backward-compat read/write */ - cliSandboxPermissions: MissionCliSandboxPermissions; - /** @deprecated kept for backward-compat read/write */ - inProcessMode: MissionInProcessPermissionMode; - /** Smart token budget configuration (persisted as mission-level default) */ - smartBudget: SmartBudgetConfig; -}; - -export const DEFAULT_ORCHESTRATOR_MODEL: ModelConfig = { - provider: "claude", - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", -}; - -export const DEFAULT_PERMISSION_CONFIG: MissionPermissionConfig = { - providers: { - claude: "full-auto", - codex: "default", - opencode: "full-auto", - codexSandbox: "workspace-write", - }, -}; - -export const DEFAULT_SMART_BUDGET: SmartBudgetConfig = { - enabled: false, - fiveHourThresholdUsd: 10, - weeklyThresholdUsd: 50, -}; - -export const DEFAULT_MISSION_SETTINGS_DRAFT: MissionSettingsDraft = { - defaultOrchestratorModel: { ...DEFAULT_ORCHESTRATOR_MODEL }, - teammatePlanMode: "auto", - permissionConfig: { ...DEFAULT_PERMISSION_CONFIG }, - cliMode: "full-auto", - cliSandboxPermissions: "workspace-write", - inProcessMode: "full-auto", - smartBudget: { ...DEFAULT_SMART_BUDGET }, -}; - -/* ════════════════════ PURE UTILITY FUNCTIONS ════════════════════ */ - -export function isRecord(value: unknown): value is Record<string, unknown> { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -export function isDisplayOnlyTaskStep( - step: { metadata?: unknown } | null | undefined, -): boolean { - const metadata = isRecord(step?.metadata) ? step.metadata : null; - if (!metadata) return false; - if (metadata.isTask === true || metadata.displayOnlyTask === true) return true; - const stepType = typeof metadata.stepType === "string" ? metadata.stepType.trim().toLowerCase() : ""; - if (stepType !== "task") return false; - return metadata.spawnedByCoordinator !== true && typeof metadata.workerName !== "string"; -} - -export function filterExecutionSteps<T extends { metadata?: unknown }>(steps: T[]): T[] { - return steps.filter((step) => !isDisplayOnlyTaskStep(step)); -} - -export function readBool(primary: unknown, fallback: unknown, defaultValue: boolean): boolean { - if (typeof primary === "boolean") return primary; - if (typeof fallback === "boolean") return fallback; - return defaultValue; -} - -export function readString(primary: unknown, fallback: unknown, defaultValue: string): string { - if (typeof primary === "string" && primary.length > 0) return primary; - if (typeof fallback === "string" && fallback.length > 0) return fallback; - return defaultValue; -} - -export function toPlannerProvider(value: string): PlannerProvider { - if (value === "auto") return "auto"; - if (value === "claude" || value === "codex") return value; - const model = getModelById(value); - if (model?.family === "anthropic") return "claude"; - if (model?.family === "openai") return "codex"; - return "auto"; -} - -/** Convert a legacy PlannerProvider string to a full ModelConfig. */ -export function plannerProviderToModelConfig(provider: PlannerProvider): ModelConfig { - if (provider === "codex") { - return { - provider: "codex", - modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5", - thinkingLevel: "medium", - }; - } - // "auto" and "claude" both default to claude - return { ...DEFAULT_ORCHESTRATOR_MODEL }; -} - -/** Convert a ModelConfig back to a legacy PlannerProvider for backward compat. */ -export function modelConfigToPlannerProvider(config: ModelConfig): PlannerProvider { - const model = config.modelId ? getModelById(config.modelId) : undefined; - if (model?.family === "openai") return "codex"; - if (model?.family === "anthropic") return "claude"; - return "auto"; -} - -export function toTeammatePlanMode(value: string): TeammatePlanMode { - return value === "off" || value === "required" || value === "auto" ? value : "auto"; -} - -export function toCliMode(value: string): MissionCliPermissionMode { - return value === "read-only" || value === "edit" || value === "full-auto" ? value : "full-auto"; -} - -export function toCliSandboxPermissions(value: string): MissionCliSandboxPermissions { - return value === "read-only" || value === "workspace-write" || value === "danger-full-access" ? value : "workspace-write"; -} - -export function toInProcessMode(value: string): MissionInProcessPermissionMode { - if (value === "plan" || value === "edit" || value === "full-auto") return value; - return "full-auto"; -} - -export function formatElapsed(startedAt: string | null, endedAt?: string | null): string { - if (!startedAt) return "--"; - const end = endedAt ? Date.parse(endedAt) : Date.now(); - const delta = Math.max(0, end - Date.parse(startedAt)); - const secs = Math.floor(delta / 1000); - if (secs < 60) return `${secs}s`; - const mins = Math.floor(secs / 60); - if (mins < 60) return `${mins}m ${secs % 60}s`; - const hours = Math.floor(mins / 60); - return `${hours}h ${mins % 60}m`; -} - -export function compactText(value: string, maxChars = 140): string { - const normalized = value.replace(/\s+/g, " ").trim(); - if (!normalized.length) return ""; - if (normalized.length <= maxChars) return normalized; - return `${normalized.slice(0, maxChars - 1)}...`; -} - -export function looksLikeLowSignalNoise(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed.length) return true; - if (/^streaming(?:\.\.\.)?$/i.test(trimmed)) return true; - if (/^usage$/i.test(trimmed)) return true; - if (/^[\-dlcbps][rwx\-@+]{8,}/i.test(trimmed)) return true; - if (/^[A-Z0-9 .:_()/-]{24,}$/.test(trimmed)) return true; - if (!/\s/.test(trimmed) && trimmed.length < 24 && !/[.!?]/.test(trimmed)) return true; - if (/^[A-Za-z]+$/.test(trimmed) && trimmed.length < 24) return true; - return false; -} - -function titleCaseQuestionLabel(raw: string): string { - return raw - .split(/[\s_-]+/) - .map((token) => token ? `${token.charAt(0).toUpperCase()}${token.slice(1).toLowerCase()}` : "") - .join(" ") - .trim(); -} - -export function getQuestionOwnerLabelFromMetadata(metadata: unknown): string | null { - const meta = isRecord(metadata) ? metadata : null; - if (!meta) return null; - const explicitLabel = typeof meta.questionOwnerLabel === "string" ? meta.questionOwnerLabel.trim() : ""; - if (explicitLabel.length > 0) return explicitLabel; - - const ownerKind = typeof meta.questionOwnerKind === "string" ? meta.questionOwnerKind.trim().toLowerCase() : ""; - if (ownerKind === "coordinator") return "Coordinator question"; - if (ownerKind === "planner") return "Planner question"; - if (ownerKind === "developer") return "Developer question"; - if (ownerKind === "validator") return "Validator question"; - if (ownerKind === "tester") return "Tester question"; - - const phaseName = typeof meta.phaseName === "string" ? meta.phaseName.trim() : ""; - const phase = typeof meta.phase === "string" ? meta.phase.trim() : ""; - const normalizedPhase = (phase || phaseName).toLowerCase(); - if (normalizedPhase === "planning") return "Planner question"; - if (normalizedPhase === "development") return "Developer question"; - if (normalizedPhase === "validation") return "Validator question"; - if (normalizedPhase === "testing") return "Tester question"; - - const source = typeof meta.source === "string" ? meta.source.trim().toLowerCase() : ""; - if (source === "request_user_input") return "Coordinator question"; - if ((source === "ask_user" || source === "manual_input") && (phaseName || phase)) { - return `${titleCaseQuestionLabel(phaseName || phase)} question`; - } - return null; -} - -export function getMissionInterventionOwnerLabel( - intervention: Pick<MissionIntervention, "interventionType" | "metadata"> | null | undefined, -): string | null { - if (!intervention || intervention.interventionType !== "manual_input") return null; - return getQuestionOwnerLabelFromMetadata(intervention.metadata); -} - -function titleizeMissionWorkerToken(value: string): string { - if (!value.length) return value; - return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); -} - -function inferMissionWorkerPhase(raw: string): string | null { - const normalized = raw.trim().toLowerCase(); - if (!normalized.length) return null; - if (normalized.startsWith("planner") || normalized.startsWith("plan")) return "Planning"; - if (normalized.startsWith("dev") || normalized.startsWith("implement")) return "Development"; - if (normalized.startsWith("integration") || normalized.startsWith("integrate")) return "Integration"; - if (normalized.startsWith("validator") || normalized.startsWith("validate")) return "Validation"; - if (normalized.startsWith("test")) return "Testing"; - if (normalized.startsWith("review")) return "Review"; - if (normalized.startsWith("closeout") || normalized.startsWith("close out")) return "Closeout"; - return null; -} - -function humanizeMissionWorkerText(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed.length) return "Worker"; - const withoutPrefix = trimmed - .replace(/^Worker:\s*/i, "") - .replace(/^worker[_:-]+/i, "") - .replace(/^teammate[_:-]+/i, ""); - const withoutTimestamp = withoutPrefix.replace(/[_-]\d{10,}$/, ""); - const withColon = withoutTimestamp.replace(/__/g, ": "); - return withColon - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function canonicalMissionWorkerPresentation(human: string, stepKey?: string | null): { - label: string; - fullLabel: string; - phaseLabel: string; -} | null { - const combined = `${human} ${stepKey ?? ""}`.toLowerCase(); - const phaseMatch = - /\bread[- ]only planning worker\b/.test(combined) || /\bplanning worker\b/.test(combined) - ? { phase: "Planning", label: "Planning worker" } - : /\b(development|implementation) worker\b/.test(combined) - ? { phase: "Development", label: "Development worker" } - : /\bintegration worker\b/.test(combined) - ? { phase: "Integration", label: "Integration worker" } - : /\b(testing|test) worker\b/.test(combined) - ? { phase: "Testing", label: "Testing worker" } - : /\b(validation|validator) worker\b/.test(combined) - ? { phase: "Validation", label: "Validation worker" } - : /\bcloseout worker\b/.test(combined) || /\bclose out worker\b/.test(combined) - ? { phase: "Closeout", label: "Closeout worker" } - : null; - if (!phaseMatch) return null; - return { - label: phaseMatch.label, - fullLabel: `${phaseMatch.phase}: ${phaseMatch.label}`, - phaseLabel: phaseMatch.phase, - }; -} - -export function formatMissionWorkerPresentation(args: { - title?: string | null; - stepKey?: string | null; -}): { - label: string; - fullLabel: string; - phaseLabel: string | null; -} { - const raw = (args.title && args.title.trim().length ? args.title : args.stepKey) ?? "Worker"; - const human = humanizeMissionWorkerText(raw); - const canonical = canonicalMissionWorkerPresentation(human, args.stepKey); - if (canonical) return canonical; - const [lead, ...rest] = human.split(":"); - const inferredPhase = inferMissionWorkerPhase(lead ?? "") ?? inferMissionWorkerPhase(args.stepKey ?? ""); - if (rest.length > 0 && inferredPhase) { - const detail = rest.join(":").trim(); - return { - label: detail.length ? detail : inferredPhase, - fullLabel: detail.length ? `${inferredPhase}: ${detail}` : inferredPhase, - phaseLabel: inferredPhase, - }; - } - const tokens = human.split(/\s+/).filter(Boolean); - if (tokens.length > 1) { - const maybePhase = inferMissionWorkerPhase(tokens[0] ?? ""); - if (maybePhase) { - const detail = tokens.slice(1).join(" ").trim(); - return { - label: detail.length ? detail : maybePhase, - fullLabel: detail.length ? `${maybePhase}: ${detail}` : maybePhase, - phaseLabel: maybePhase, - }; - } - } - return { - label: human, - fullLabel: inferredPhase && human !== inferredPhase ? `${inferredPhase}: ${human}` : human, - phaseLabel: inferredPhase ?? (human === "Worker" ? null : titleizeMissionWorkerToken(human.split(" ")[0] ?? "")), - }; -} - -export function isPlannerStreamMessage(msg: OrchestratorChatMessage): boolean { - if (msg.role !== "worker") return false; - if (!isRecord(msg.metadata)) return false; - const planner = isRecord(msg.metadata.planner) ? msg.metadata.planner : null; - return Boolean(planner && planner.stream === true); -} - -export function collapsePlannerStreamMessages(messages: OrchestratorChatMessage[]): OrchestratorChatMessage[] { - if (messages.length < 2) return messages; - const collapsed: OrchestratorChatMessage[] = []; - let activePlannerMessage: OrchestratorChatMessage | null = null; - - for (const message of messages) { - if (!isPlannerStreamMessage(message)) { - if (activePlannerMessage) { - collapsed.push(activePlannerMessage); - activePlannerMessage = null; - } - collapsed.push(message); - continue; - } - - if ( - activePlannerMessage - && isPlannerStreamMessage(activePlannerMessage) - && activePlannerMessage.threadId === message.threadId - && activePlannerMessage.sourceSessionId === message.sourceSessionId - ) { - const sep: string = - activePlannerMessage.content.endsWith("\n") || message.content.startsWith("\n") - ? "" - : "\n"; - const prev: OrchestratorChatMessage = activePlannerMessage; - activePlannerMessage = { - ...prev, - content: `${prev.content}${sep}${message.content}`, - timestamp: message.timestamp - }; - continue; - } - - if (activePlannerMessage) collapsed.push(activePlannerMessage); - activePlannerMessage = { ...message }; - } - - if (activePlannerMessage) collapsed.push(activePlannerMessage); - return collapsed; -} - -export function stepIntentSummary(step: OrchestratorStep): string { - const meta = isRecord(step.metadata) ? step.metadata : {}; - const planStep = isRecord(meta.planStep) ? meta.planStep : {}; - const candidates: string[] = []; - if (typeof planStep.description === "string" && planStep.description.trim().length) { - candidates.push(planStep.description); - } - if (typeof meta.instructions === "string" && meta.instructions.trim().length) { - candidates.push(meta.instructions); - } - if (typeof meta.doneCriteria === "string" && meta.doneCriteria.trim().length) { - candidates.push(`Completion target: ${meta.doneCriteria}`); - } - if (typeof meta.stepType === "string" && meta.stepType.trim().length) { - candidates.push(`Task type: ${meta.stepType}`); - } - const first = candidates.find((entry) => entry.trim().length > 0); - return compactText(first ?? "No additional detail yet."); -} - -export function resolveStepHeartbeatAt(args: { - step: OrchestratorStep; - attempts: OrchestratorAttempt[]; - claims: OrchestratorClaim[]; -}): string | null { - const toEpoch = (iso: string): number => { - const ts = Date.parse(iso); - return Number.isFinite(ts) ? ts : -1; - }; - const pickMostRecent = (claims: OrchestratorClaim[]): string | null => { - if (!claims.length) return null; - return [...claims] - .sort((a, b) => toEpoch(b.heartbeatAt) - toEpoch(a.heartbeatAt))[0]?.heartbeatAt ?? null; - }; - - const latestAttemptId = args.attempts[0]?.id ?? null; - if (latestAttemptId) { - const latestAttemptHeartbeat = pickMostRecent( - args.claims.filter((claim) => claim.attemptId === latestAttemptId) - ); - if (latestAttemptHeartbeat) return latestAttemptHeartbeat; - } - - const attemptIds = new Set(args.attempts.map((attempt) => attempt.id)); - return pickMostRecent( - args.claims.filter((claim) => - claim.stepId === args.step.id || (claim.attemptId ? attemptIds.has(claim.attemptId) : false) - ) - ); -} - -export function heartbeatAgeMinutes(iso: string | null): number | null { - if (!iso) return null; - const ts = Date.parse(iso); - if (Number.isNaN(ts)) return null; - return Math.max(0, (Date.now() - ts) / 60_000); -} - -function prettifyStepKey(value: string): string { - return value - .replace(/^validate_worker_/, "Validate ") - .replace(/^worker_/, "") - .replace(/_\d{8,}$/, "") - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function narrativeStepLabel(ev: { - stepId?: string | null; - stepKey?: string | null; - stepTitle?: string | null; - title?: string | null; - detail?: unknown; -}): string { - const detail = isRecord(ev.detail) ? ev.detail : null; - const candidates = [ - ev.stepTitle, - ev.title, - detail?.stepTitle, - detail?.title, - detail?.stepKey, - ev.stepKey, - ]; - for (const candidate of candidates) { - if (typeof candidate !== "string") continue; - const trimmed = candidate.trim(); - if (!trimmed) continue; - return `"${prettifyStepKey(trimmed)}"`; - } - return ev.stepId ? "this step" : "the step"; -} - -/** Turn a raw orchestrator timeline event into a human-readable sentence. */ -export function narrativeForEvent(ev: { - eventType: string; - reason: string; - stepId?: string | null; - stepKey?: string | null; - stepTitle?: string | null; - title?: string | null; - detail?: unknown; -}): string { - const r = ev.reason.toLowerCase(); - const stepLabel = narrativeStepLabel(ev); - - if (ev.eventType === "run_status_changed" || ev.eventType === "run_created" || - ev.eventType === "run_resumed" || ev.eventType === "run_canceled") { - if (r.includes("started") || ev.eventType === "run_created") return "Mission execution started"; - if (r.includes("completed") || r.includes("succeeded")) return "All steps completed successfully"; - if (r.includes("failed")) return `Mission failed \u2014 ${ev.reason}`; - if (r.includes("paused")) return "Execution paused \u2014 awaiting intervention"; - if (r.includes("resumed") || ev.eventType === "run_resumed") return "Execution resumed"; - if (r.includes("canceled") || ev.eventType === "run_canceled") return "Run canceled by user"; - return `Mission status updated: ${ev.reason}`; - } - - if (ev.eventType === "step_status_changed" || ev.eventType === "step_registered" || - ev.eventType === "step_dependencies_resolved" || ev.eventType === "step_skipped") { - if (ev.eventType === "step_registered") return `Added ${stepLabel} to the plan`; - if (ev.eventType === "step_dependencies_resolved") return `Dependencies resolved for ${stepLabel}`; - if (ev.eventType === "step_skipped" || r.includes("skipped")) return `Skipped ${stepLabel}`; - if (r.includes("ready")) return `${stepLabel} is ready for execution`; - if (r.includes("running") || r.includes("started")) return `Worker picked up ${stepLabel}`; - if (r.includes("succeeded")) return `${stepLabel} completed successfully`; - if (r.includes("failed")) return `${stepLabel} failed: ${ev.reason}`; - return `Step update: ${ev.reason}`; - } - - if (ev.eventType === "attempt_started" || ev.eventType === "attempt_completed" || - ev.eventType === "attempt_blocked" || ev.eventType === "attempt_retry_scheduled" || - ev.eventType === "attempt_recovered_after_restart") { - if (ev.eventType === "attempt_started" || r.includes("started") || r.includes("running")) return "Agent started working on attempt"; - if (r.includes("succeeded")) return "Agent finished work \u2014 output ready for evaluation"; - if (r.includes("failed")) return `Agent encountered an error: ${ev.reason}`; - if (ev.eventType === "attempt_blocked") return "Attempt blocked \u2014 waiting for dependencies"; - if (ev.eventType === "attempt_retry_scheduled") return "Retry scheduled for failed attempt"; - if (ev.eventType === "attempt_recovered_after_restart") return "Attempt recovered after process restart"; - return `Worker activity: ${ev.reason}`; - } - - if (ev.eventType === "claim_acquired" || ev.eventType === "claim_released" || - ev.eventType === "claim_expired" || ev.eventType === "claim_heartbeat") { - if (r.includes("acquired") || ev.eventType === "claim_acquired") return "Agent claimed resources for execution"; - if (r.includes("released") || ev.eventType === "claim_released") return "Agent released resources"; - if (ev.eventType === "claim_expired") return "Resource claim expired"; - if (ev.eventType === "claim_heartbeat") return "Worker heartbeat received"; - return `Resource update: ${ev.reason}`; - } - - if (ev.eventType === "autopilot_advance") return `Autopilot advanced: ${ev.reason}`; - if (ev.eventType === "autopilot_attempt_start_failed") return `Autopilot failed to start attempt: ${ev.reason}`; - - if (ev.eventType === "context_snapshot_created") return "Context snapshot saved for future reference"; - if (ev.eventType === "context_pressure_warning") return "Context window pressure detected \u2014 may need to compact"; - if (ev.eventType === "integration_chain_started") return "Integration merge chain started"; - - if (ev.eventType === "validation_contract_unfulfilled") return "Validation contract unfulfilled"; - if (ev.eventType === "validation_self_check_reminder") return "Self-check reminder issued"; - if (ev.eventType === "validation_auto_spawned") return "Validator auto-spawned"; - if (ev.eventType === "validation_gate_blocked") return "Spawn blocked by validation gate"; - if (ev.eventType === "validation_reported") return "Validation result reported"; - if (ev.eventType === "validation_escalated") return "Validation escalated"; - - if (ev.eventType === "intervention_opened") return "Intervention opened"; - if (ev.eventType === "intervention_resolved") return "Intervention resolved"; - - if (ev.eventType === "budget_warning") return "Budget warning issued"; - if (ev.eventType === "budget_exceeded") return "Budget exceeded"; - if (ev.eventType === "budget_updated") return "Budget updated"; - if (ev.eventType === "budget_hard_cap_triggered") return "Budget hard cap triggered"; - - if (ev.eventType === "recovery_loop_started") return "Recovery loop started"; - if (ev.eventType === "recovery_loop_exhausted") return "Recovery retries exhausted"; - - if (ev.eventType === "fan_out_dispatched") return "Fan-out dispatched"; - if (ev.eventType === "fan_out_complete") return "Fan-out complete"; - - if (ev.eventType === "phase_transition") return "Phase transitioned"; - if (ev.eventType === "plan_revised") { - const detail = isRecord(ev.detail) ? ev.detail : null; - const reason = typeof detail?.reason === "string" && detail.reason.trim().length > 0 - ? detail.reason - : ev.reason; - return reason ? `Plan revised: ${compactText(reason, 110)}` : "Plan revised"; - } - if (ev.eventType === "coordinator_steering") { - const detail = isRecord(ev.detail) ? ev.detail : null; - const directive = typeof detail?.directive === "string" && detail.directive.trim().length > 0 - ? detail.directive - : ev.reason; - const target = typeof detail?.targetStepKey === "string" && detail.targetStepKey.trim().length > 0 - ? ` for ${prettifyStepKey(detail.targetStepKey)}` - : ""; - return directive ? `User steering${target}: ${compactText(directive, 110)}` : "User steering directive"; - } - if (ev.eventType === "coordinator_broadcast") return "Coordinator broadcast"; - - return ev.reason || "Event recorded"; -} - -/** Pick a Phosphor icon component for a timeline event type. */ -export function iconForEventType(eventType: string): React.ElementType { - if (eventType.startsWith("run_") || eventType === "run_status_changed") return Rocket; - if (eventType.startsWith("step_") || eventType === "step_status_changed") return CircleHalf; - if (eventType.startsWith("attempt_")) return Robot; - if (eventType.startsWith("claim_")) return Shield; - if (eventType.startsWith("autopilot")) return Lightning; - if (eventType.startsWith("context_")) return GitBranch; - if (eventType.startsWith("validation_")) return Shield; - if (eventType.startsWith("intervention_")) return Lightning; - if (eventType.startsWith("budget_")) return Pulse; - if (eventType.startsWith("recovery_")) return Robot; - if (eventType.startsWith("phase_")) return CircleHalf; - if (eventType.startsWith("fan_out_")) return Robot; - if (eventType.startsWith("coordinator_")) return ChatCircle; - if (eventType.startsWith("worker_")) return Robot; - if (eventType.startsWith("integration_")) return GitBranch; - if (eventType === "user_directive") return ChatCircle; - return Pulse; -} - -/** Hex color for a timeline event type icon. */ -export function iconHexForEventType(eventType: string, reason: string): string { - const r = reason.toLowerCase(); - if (r.includes("failed") || r.includes("error")) return "#EF4444"; - if (r.includes("succeeded") || r.includes("completed") || r.includes("success")) return "#22C55E"; - if (r.includes("paused") || r.includes("blocked")) return "#F59E0B"; - if (eventType.startsWith("run_")) return "#22C55E"; - if (eventType.startsWith("step_")) return "#3B82F6"; - if (eventType.startsWith("attempt_")) return "#A78BFA"; - if (eventType.startsWith("claim_")) return "#F59E0B"; - if (eventType.startsWith("autopilot")) return "#A78BFA"; - if (eventType === "user_directive") return "#06B6D4"; - return COLORS.textMuted; -} - -export function narrativeSummary( - events: Array<{ eventType: string; reason: string; stepId?: string | null }>, - directives: SteeringEntry[] = [] -): string[] { - const lines: string[] = []; - for (const d of directives) { - lines.push(`User directive: ${d.directive}`); - } - for (const ev of events.slice(0, 12)) { - lines.push(narrativeForEvent(ev)); - } - return lines; -} - -export function formatMetricSample(sample: MissionMetricSample): string { - const rounded = Number.isFinite(sample.value) ? sample.value.toFixed(sample.value >= 100 ? 0 : 2) : "0"; - if (sample.unit && sample.unit.trim().length) return `${rounded} ${sample.unit}`; - return rounded; -} - -export const PLAN_DONE_STATUSES = new Set<OrchestratorStepStatus>(["succeeded", "skipped", "superseded", "canceled"]); - -export function statusGlyph(status: OrchestratorStepStatus): string { - if (status === "succeeded" || status === "skipped") return "\u2713"; - if (status === "running" || status === "ready") return "\u25CF"; - if (status === "failed") return "\u2717"; - if (status === "superseded") return "~"; - return "\u25CB"; -} - -/* ════════════════════ ELAPSED TIME COMPONENT ════════════════════ */ - -/** Self-ticking elapsed-time display — isolates the 1s timer from the parent tree. */ -export function ElapsedTime({ startedAt, endedAt }: { startedAt: string | null; endedAt?: string | null }) { - const [, setTick] = React.useState(0); - const isTerminal = !!endedAt; - React.useEffect(() => { - if (isTerminal || !startedAt) return; - const timer = window.setInterval(() => setTick((t) => t + 1), 1000); - return () => window.clearInterval(timer); - }, [isTerminal, startedAt]); - return React.createElement(React.Fragment, null, formatElapsed(startedAt, endedAt)); -} - -/* ════════════════════ ERROR CLASSIFICATION (VAL-UX-008) ════════════════════ */ - -export type ErrorSource = "ADE" | "Provider" | "Executor" | "Runtime"; - -export const ERROR_SOURCE_COLORS: Record<ErrorSource, string> = { - ADE: "#EF4444", - Provider: "#F59E0B", - Executor: "#3B82F6", - Runtime: "#71717A", -}; - -/** - * Classify an error message into its source. (VAL-UX-008) - * ADE = orchestrator / internal bugs. Provider = AI API / rate-limit / quota. - * Executor = CLI / process spawn. Runtime = env / config / sandbox. - */ -export function classifyErrorSource(message: string): ErrorSource { - const m = message.toLowerCase(); - // Provider errors (rate limit, quota, API, model) - if ( - m.includes("rate limit") || - m.includes("rate_limit") || - m.includes("429") || - m.includes("quota") || - m.includes("api key") || - m.includes("api_key") || - m.includes("authentication") || - m.includes("unauthorized") || - m.includes("anthropic") || - m.includes("openai") || - m.includes("model not found") || - m.includes("overloaded") || - m.includes("capacity") - ) { - return "Provider"; - } - // Executor errors (CLI, spawn, process) - if ( - m.includes("spawn") || - m.includes("enoent") || - m.includes("process exit") || - m.includes("exit code") || - m.includes("timed out") || - m.includes("timeout") || - m.includes("session") || - m.includes("executor") || - m.includes("worker crash") || - m.includes("startup_failure") - ) { - return "Executor"; - } - // Runtime errors (env, config, sandbox) - if ( - m.includes("sandbox") || - m.includes("permission") || - m.includes("config") || - m.includes("gmail") || - m.includes("environment") || - m.includes("worktree") || - m.includes("lane") - ) { - return "Runtime"; - } - // Default to ADE for internal / orchestrator errors - return "ADE"; -} - -/* ════════════════════ PROGRESS COMPUTATION (VAL-UX-003) ════════════════════ */ - -/** - * Compute accurate progress from execution steps, excluding superseded and - * retry variants from both numerator and denominator. (VAL-UX-003) - */ -export function computeProgress( - steps: Array<Pick<OrchestratorStep, "status" | "metadata">>, - options: { includeDisplayOnlyTaskShells?: boolean } = {}, -): { completed: number; total: number; pct: number } { - // Filter out display-only task steps and superseded/retry variants - const meaningful = steps.filter((step) => { - const metadata = isRecord(step.metadata) ? step.metadata : null; - if (isDisplayOnlyTaskStep(step)) { - if (!options.includeDisplayOnlyTaskShells) return false; - if (metadata?.plannerLaunchTracker === true || metadata?.systemManaged === true) return false; - } - if (step.status === "superseded") return false; - // If the step is a retry variant (has retryOf metadata), exclude it - if (metadata?.retryOf) return false; - return true; - }); - - const completed = meaningful.filter( - (s) => s.status === "succeeded" || s.status === "skipped" || s.status === "canceled", - ).length; - const total = meaningful.length; - const pct = total > 0 ? Math.round((completed / total) * 100) : 0; - return { completed, total, pct }; -} - -/* ════════════════════ FEED DEDUPLICATION (VAL-UX-002) ════════════════════ */ - -export type CollapsedFeedMessage<T> = { - item: T; - count: number; - collapsed: T[]; -}; - -/** - * Collapse consecutive duplicate feed events into single entries with count. - * Groups by eventType and stepId (when present). (VAL-UX-002) - */ -export function collapseFeedMessages<T extends { eventType: string; stepId?: string | null }>( - events: T[], -): CollapsedFeedMessage<T>[] { - if (events.length === 0) return []; - - const result: CollapsedFeedMessage<T>[] = []; - let current: CollapsedFeedMessage<T> = { item: events[0]!, count: 1, collapsed: [events[0]!] }; - - for (let i = 1; i < events.length; i++) { - const ev = events[i]!; - const prev = current.item; - if (ev.eventType === prev.eventType && ev.stepId === prev.stepId) { - current.count++; - current.collapsed.push(ev); - // Keep the most recent event as the representative - current.item = ev; - } else { - result.push(current); - current = { item: ev, count: 1, collapsed: [ev] }; - } - } - result.push(current); - return result; -} - -/* ════════════════════ LIFECYCLE ACTIONS (VAL-UX-006) ════════════════════ */ - -export type LifecycleAction = "stop_run" | "cancel_mission" | "archive_mission"; - -export const LIFECYCLE_ACTIONS: Record<LifecycleAction, { - label: string; - color: string; - confirmText: string | null; -}> = { - stop_run: { - label: "Stop Run", - color: "#F59E0B", // amber - confirmText: null, // no confirmation for stop - }, - cancel_mission: { - label: "Cancel Mission", - color: "#EF4444", // red - confirmText: "This will cancel the entire mission. Are you sure?", - }, - archive_mission: { - label: "Archive Mission", - color: "#71717A", // gray - confirmText: null, - }, -}; - -/** - * Determine which lifecycle actions are available for a given mission + run state. - * (VAL-UX-006) - */ -export function getAvailableLifecycleActions( - missionStatus: MissionStatus, - runStatus: string | null, -): LifecycleAction[] { - const actions: LifecycleAction[] = []; - const hasActiveRun = runStatus != null && !["succeeded", "failed", "canceled"].includes(runStatus); - const isTerminal = TERMINAL_MISSION_STATUSES.has(missionStatus); - - // Stop Run: only when there's an active run - if (hasActiveRun) { - actions.push("stop_run"); - } - - // Cancel Mission: when mission is not terminal and there's something to cancel - if (!isTerminal) { - actions.push("cancel_mission"); - } - - // Archive Mission: only for terminal missions - if (isTerminal) { - actions.push("archive_mission"); - } - - return actions; -} - -/* ════════════════════ USAGE FORMATTING ════════════════════ */ - -/** Format milliseconds into a human-readable reset countdown. (VAL-USAGE-004) */ -export function formatResetCountdown(ms: number): string { - if (ms <= 0) return "resets now"; - const hours = Math.floor(ms / 3_600_000); - const mins = Math.floor((ms % 3_600_000) / 60_000); - if (hours > 0) return `resets in ${hours}h ${mins}m`; - return `resets in ${mins}m`; -} - -/** Return color for a usage percentage: green < 60%, amber 60-80%, red > 80%. */ -export function usagePercentColor(pct: number): string { - if (pct > 80) return "#EF4444"; - if (pct >= 60) return "#F59E0B"; - return "#22C55E"; -} diff --git a/apps/desktop/src/renderer/components/missions/missionInterventionRouting.ts b/apps/desktop/src/renderer/components/missions/missionInterventionRouting.ts deleted file mode 100644 index 7c8b9eefa..000000000 --- a/apps/desktop/src/renderer/components/missions/missionInterventionRouting.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { MissionIntervention } from "../../../shared/types"; -import { isRecord } from "./missionHelpers"; -import type { MissionsStore } from "./useMissionsStore"; - -type MissionInterventionRouterStore = Pick< - MissionsStore, - | "selectedMission" - | "runGraph" - | "setActiveInterventionId" - | "setLogsFocusInterventionId" - | "setChatJumpTarget" - | "setSelectedStepId" - | "setPlanSubview" - | "setActiveTab" ->; - -function resolveOpenIntervention( - store: MissionInterventionRouterStore, - interventionOrId: MissionIntervention | string, -): MissionIntervention | null { - const interventions = store.selectedMission?.interventions ?? []; - const intervention = - typeof interventionOrId === "string" - ? interventions.find((entry) => entry.id === interventionOrId) ?? null - : interventionOrId; - return intervention?.status === "open" ? intervention : null; -} - -export function routeMissionIntervention( - store: MissionInterventionRouterStore, - interventionOrId: MissionIntervention | string, -): boolean { - const intervention = resolveOpenIntervention(store, interventionOrId); - if (!intervention) return false; - - if (intervention.interventionType === "manual_input") { - store.setActiveInterventionId(intervention.id); - return true; - } - - const metadata = isRecord(intervention.metadata) ? intervention.metadata : {}; - const interventionRunId = typeof metadata.runId === "string" && metadata.runId.trim().length > 0 - ? metadata.runId.trim() - : null; - const interventionStepId = typeof metadata.stepId === "string" && metadata.stepId.trim().length > 0 - ? metadata.stepId.trim() - : null; - const interventionStepKey = typeof metadata.stepKey === "string" && metadata.stepKey.trim().length > 0 - ? metadata.stepKey.trim() - : null; - const interventionAttemptId = typeof metadata.attemptId === "string" && metadata.attemptId.trim().length > 0 - ? metadata.attemptId.trim() - : null; - const reasonCode = typeof metadata.reasonCode === "string" && metadata.reasonCode.trim().length > 0 - ? metadata.reasonCode.trim() - : null; - const currentRunId = store.runGraph?.run.id ?? null; - const sameRun = interventionRunId ? interventionRunId === currentRunId : Boolean(currentRunId); - const resolvedStepId = - sameRun - && interventionStepId - && store.runGraph?.steps.some((step) => step.id === interventionStepId) - ? interventionStepId - : null; - - if (reasonCode === "coordinator_unavailable" || reasonCode === "coordinator_recovery_failed") { - store.setLogsFocusInterventionId(null); - store.setChatJumpTarget({ kind: "coordinator", runId: interventionRunId ?? currentRunId }); - store.setActiveTab("chat"); - return true; - } - - if (intervention.interventionType === "failed_step") { - if (resolvedStepId) { - store.setSelectedStepId(resolvedStepId); - store.setPlanSubview("board"); - store.setLogsFocusInterventionId(intervention.id); - store.setActiveTab("plan"); - return true; - } - if (sameRun && (interventionAttemptId || interventionStepKey)) { - store.setLogsFocusInterventionId(null); - store.setChatJumpTarget({ - kind: "worker", - runId: interventionRunId ?? currentRunId, - stepId: null, - stepKey: interventionStepKey, - attemptId: interventionAttemptId, - }); - store.setActiveTab("chat"); - return true; - } - } - - store.setLogsFocusInterventionId(intervention.id); - store.setActiveTab("history"); - return true; -} diff --git a/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts b/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts deleted file mode 100644 index 7add203f0..000000000 --- a/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { ModelConfig, PhaseCard, PhaseProfile } from "../../../shared/types"; -import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; - -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"; - -const DEFAULT_MODELS: Record<string, ModelConfig> = { - planning: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, - development: { modelId: DEFAULT_CODEX_PHASE_MODEL_ID, thinkingLevel: "medium" }, - integration: { modelId: DEFAULT_CODEX_PHASE_MODEL_ID, thinkingLevel: "medium" }, - testing: { modelId: DEFAULT_CODEX_PHASE_MODEL_ID, thinkingLevel: "low" }, - validation: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, - closeout: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, -}; - -export function createBuiltInMissionPhaseCards(at: string = new Date().toISOString()): PhaseCard[] { - return [ - { - id: "builtin:planning", - phaseKey: "planning", - name: "Planning", - description: "Research, clarify requirements, and design the execution DAG.", - instructions: "Investigate the codebase, identify dependencies/risks, and produce a concrete execution plan before implementation.", - model: DEFAULT_MODELS.planning, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, maxQuestions: null }, - validationGate: { tier: "none", required: false }, - requiresApproval: true, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:development", - phaseKey: "development", - name: "Development", - description: "Implement planned work with lane-scoped workers.", - instructions: "Execute implementation tasks, update code, and publish structured progress/results.", - model: DEFAULT_MODELS.development, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:integration", - phaseKey: "integration", - name: "Integration", - description: "Join child-lane work into the mission result lane.", - instructions: "After implementation workers finish, consolidate their outputs, resolve conflicts, and assemble a single reviewable result lane.", - model: DEFAULT_MODELS.integration, - budget: {}, - orderingConstraints: { mustFollow: ["development"] }, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "All child-lane outputs are accounted for and the result lane can be inspected.", - evidenceRequirements: ["changed_files_summary", "risk_notes"], - capabilityFallback: "warn", - }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:testing", - phaseKey: "testing", - name: "Testing", - description: "Execute and stabilize test suites.", - instructions: "Run tests, collect failures, and feed remediation details back into execution.", - model: DEFAULT_MODELS.testing, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true }, - isBuiltIn: true, - isCustom: false, - position: 3, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:validation", - phaseKey: "validation", - name: "Validation", - description: "Cross-check mission output against requested outcomes.", - instructions: "Validate done criteria, audit risk, and identify remaining obligations before completion.", - model: DEFAULT_MODELS.validation, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "dedicated", required: true }, - isBuiltIn: true, - isCustom: false, - position: 4, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:closeout", - phaseKey: "closeout", - name: "Closeout", - description: "Collect final proof and present the finished mission output.", - instructions: "Confirm the result lane, summarize the final product, attach required proof, and leave the mission ready for user review.", - model: DEFAULT_MODELS.closeout, - budget: {}, - orderingConstraints: { mustBeLast: true, mustFollow: ["validation"] }, - askQuestions: { enabled: false }, - validationGate: { - tier: "self", - required: true, - criteria: "Outcome summary, changed-files summary, and remaining risks are visible before completion.", - evidenceRequirements: ["final_outcome_summary", "changed_files_summary", "risk_notes"], - capabilityFallback: "warn", - }, - isBuiltIn: true, - isCustom: false, - position: 5, - createdAt: at, - updatedAt: at, - }, - ]; -} - -export function createBuiltInMissionPhaseProfiles(cards: PhaseCard[], at: string = new Date().toISOString()): PhaseProfile[] { - const byKey = new Map(cards.map((card) => [card.phaseKey, card] as const)); - const asPhaseList = (keys: string[]): PhaseCard[] => - keys - .map((key, index) => { - const card = byKey.get(key); - if (!card) return null; - return { ...card, position: index }; - }) - .filter((card): card is PhaseCard => card != null); - - return [ - { - id: "builtin:default", - name: "Default", - description: "Planning -> Development -> Integration -> Testing -> Validation -> Closeout", - phases: asPhaseList(["planning", "development", "integration", "testing", "validation", "closeout"]), - isBuiltIn: true, - isDefault: true, - createdAt: at, - updatedAt: at, - }, - { - id: "builtin:tdd", - name: "TDD", - description: "Planning -> Testing -> Development -> Integration -> Validation -> Closeout", - phases: asPhaseList(["planning", "testing", "development", "integration", "validation", "closeout"]), - isBuiltIn: true, - isDefault: false, - createdAt: at, - updatedAt: at, - }, - ]; -} - -export function getDefaultBuiltInMissionPhaseProfile(): PhaseProfile { - const cards = createBuiltInMissionPhaseCards(); - return createBuiltInMissionPhaseProfiles(cards)[0]!; -} diff --git a/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.test.ts b/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.test.ts deleted file mode 100644 index 791d7bde2..000000000 --- a/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import { adaptMissionThreadMessagesToAgentEvents } from "./missionThreadEventAdapter"; - -describe("missionThreadEventAdapter reliability", () => { - it("preserves tool events and provenance from persisted mission chat messages", () => { - const events = adaptMissionThreadMessagesToAgentEvents([ - { - id: "msg-1", - missionId: "mission-1", - role: "worker", - content: "Using a tool", - timestamp: "2026-03-09T12:00:00.000Z", - threadId: "thread-1", - stepKey: "step-alpha", - attemptId: "attempt-1", - laneId: "lane-1", - runId: "run-1", - sourceSessionId: "session-1", - metadata: { - structuredStream: { - kind: "tool", - itemId: "tool-item-1", - parentItemId: "turn-1", - tool: "read_file", - args: { path: "src/index.ts" }, - result: { ok: true }, - status: "completed", - }, - }, - }, - ] as any); - - expect(events).toHaveLength(2); - expect(events[0]?.event.type).toBe("tool_call"); - expect(events[1]?.event.type).toBe("tool_result"); - expect(events[0]?.event).toMatchObject({ - type: "tool_call", - tool: "read_file", - itemId: "tool-item-1", - parentItemId: "turn-1", - }); - expect(events[1]?.provenance).toMatchObject({ - threadId: "thread-1", - sourceSessionId: "session-1", - attemptId: "attempt-1", - stepKey: "step-alpha", - laneId: "lane-1", - runId: "run-1", - }); - }); - - it("round-trips transcript provenance", () => { - const parsed = parseAgentChatTranscript([ - JSON.stringify({ - sessionId: "session-1", - timestamp: "2026-03-09T12:00:00.000Z", - provenance: { - threadId: "thread-1", - runId: "run-1", - attemptId: "attempt-1", - }, - event: { - type: "text", - text: "hello", - itemId: "text-1", - }, - }), - ].join("\n")); - - expect(parsed).toHaveLength(1); - expect(parsed[0]?.provenance).toMatchObject({ - threadId: "thread-1", - runId: "run-1", - attemptId: "attempt-1", - }); - }); - - it("preserves user delivery state and structured status/done metadata", () => { - const events = adaptMissionThreadMessagesToAgentEvents([ - { - id: "msg-user", - missionId: "mission-1", - role: "user", - content: "What went wrong?", - timestamp: "2026-03-09T12:00:00.000Z", - threadId: "thread-1", - runId: "run-1", - deliveryState: "delivered", - metadata: { - structuredStream: { - kind: "user_message", - text: "What went wrong?", - }, - }, - }, - { - id: "msg-status", - missionId: "mission-1", - role: "orchestrator", - content: "", - timestamp: "2026-03-09T12:00:01.000Z", - threadId: "thread-1", - runId: "run-1", - metadata: { - structuredStream: { - kind: "status", - status: "interrupted", - message: "Planner session started and then stopped almost immediately.", - turnId: "turn-1", - }, - }, - }, - { - id: "msg-done", - missionId: "mission-1", - role: "orchestrator", - content: "", - timestamp: "2026-03-09T12:00:02.000Z", - threadId: "thread-1", - runId: "run-1", - metadata: { - structuredStream: { - kind: "done", - status: "failed", - turnId: "turn-1", - modelId: "anthropic/claude-sonnet-4-6", - usage: { - inputTokens: 42, - outputTokens: 13, - }, - }, - }, - }, - ] as any); - - expect(events[0]?.event).toMatchObject({ - type: "user_message", - text: "What went wrong?", - deliveryState: "delivered", - processed: true, - }); - expect(events[1]?.event).toMatchObject({ - type: "status", - turnStatus: "interrupted", - message: "Planner session started and then stopped almost immediately.", - }); - expect(events[2]?.event).toMatchObject({ - type: "done", - status: "failed", - modelId: "anthropic/claude-sonnet-4-6", - usage: { - inputTokens: 42, - outputTokens: 13, - }, - }); - }); - - it("omits persisted internal coordinator runtime prompts", () => { - const events = adaptMissionThreadMessagesToAgentEvents([ - { - id: "msg-internal", - missionId: "mission-1", - role: "orchestrator", - content: "## ADE Mission-Control Tool Transport\nInternal prompt.", - timestamp: "2026-03-09T12:00:00.000Z", - threadId: "mission:mission-1", - runId: "run-1", - sourceSessionId: "coordinator-session", - target: { kind: "coordinator", runId: "run-1" }, - metadata: { - structuredStream: { - kind: "user_message", - text: "## ADE Mission-Control Tool Transport\nInternal prompt.", - displayText: "Continue mission coordination.", - internalKind: "coordinator_runtime_prompt", - }, - }, - }, - ] as any); - - expect(events).toHaveLength(0); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts b/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts deleted file mode 100644 index 39868de22..000000000 --- a/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts +++ /dev/null @@ -1,520 +0,0 @@ -import type { AgentChatEvent, AgentChatEventEnvelope, ModelId, OrchestratorChatMessage } from "../../../shared/types"; -import { looksLikeLowSignalNoise } from "./missionHelpers"; - -function readRecord(value: unknown): Record<string, unknown> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record<string, unknown> - : null; -} - -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function hasOwn(record: Record<string, unknown>, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} - -function normalizeTurnStatus(value: string | null): Extract<AgentChatEvent, { type: "status" }>["turnStatus"] { - switch (value) { - case "completed": - case "interrupted": - case "failed": - return value; - default: - return "started"; - } -} - -function normalizeDoneStatus(value: string | null): Extract<AgentChatEvent, { type: "done" }>["status"] { - switch (value) { - case "interrupted": - case "failed": - return value; - default: - return "completed"; - } -} - -function normalizeToolStatus(value: string | null): Extract<AgentChatEvent, { type: "tool_result" }>["status"] { - switch (value) { - case "completed": - case "failed": - return value; - default: - return "running"; - } -} - -function readDelegationContract( - value: unknown, -): Extract<AgentChatEvent, { type: "delegation_state" }>["contract"] | null { - const record = readRecord(value); - const contractId = readString(record?.contractId); - if (!record || !contractId) return null; - return record as Extract<AgentChatEvent, { type: "delegation_state" }>["contract"]; -} - -function normalizeCommandStatus(value: string | null): "running" | "completed" | "failed" { - switch (value) { - case "completed": - case "failed": - return value; - default: - return "running"; - } -} - -function normalizePlanStepStatus(value: unknown): "pending" | "in_progress" | "completed" | "failed" { - const status = typeof value === "string" ? value : ""; - switch (status) { - case "completed": - case "in_progress": - case "failed": - return status; - default: - return "pending"; - } -} - -function normalizeActivity(value: string | null): "thinking" | "working" | "editing_file" | "running_command" | "searching" | "reading" | "tool_calling" { - switch (value) { - case "working": - case "editing_file": - case "running_command": - case "searching": - case "reading": - case "tool_calling": - return value; - default: - return "thinking"; - } -} - -function normalizeDeliveryState(value: unknown): "queued" | "delivered" | "failed" | undefined { - switch (value) { - case "queued": - case "delivered": - case "failed": - return value; - default: - return undefined; - } -} - -function resolveSessionId(message: OrchestratorChatMessage, structuredStream: Record<string, unknown> | null): string { - return ( - readString(structuredStream?.sessionId) - ?? readString(message.sourceSessionId) - ?? readString(message.threadId) - ?? readString(message.attemptId) - ?? readString(message.runId) - ?? `mission-thread:${message.id}` - ); -} - -function resolveItemId(message: OrchestratorChatMessage, structuredStream: Record<string, unknown> | null, suffix: string): string { - return readString(structuredStream?.itemId) ?? `${message.id}:${suffix}`; -} - -function resolveTurnId(message: OrchestratorChatMessage, structuredStream: Record<string, unknown> | null, sessionId: string): string | undefined { - return readString(structuredStream?.turnId) ?? (message.role === "user" ? `${sessionId}:user:${message.id}` : undefined); -} - -function resolveErrorInfo(value: unknown): Extract<AgentChatEvent, { type: "error" }>["errorInfo"] | undefined { - if (typeof value === "string" && value.trim().length > 0) return value.trim(); - const record = readRecord(value); - const category = readString(record?.category); - if (!record || !category) return undefined; - if (!["auth", "rate_limit", "budget", "network", "unknown"].includes(category)) return undefined; - return { - category: category as "auth" | "rate_limit" | "budget" | "network" | "unknown", - provider: readString(record.provider) ?? undefined, - model: readString(record.model) ?? undefined, - }; -} - -function toToolEvents( - message: OrchestratorChatMessage, - structuredStream: Record<string, unknown>, - sessionId: string, - turnId: string | undefined, -): AgentChatEventEnvelope[] { - const tool = readString(structuredStream.tool) ?? "tool"; - const itemId = resolveItemId(message, structuredStream, "tool"); - const parentItemId = readString(structuredStream.parentItemId) ?? undefined; - const envelopes: AgentChatEventEnvelope[] = []; - - if (hasOwn(structuredStream, "args")) { - envelopes.push({ - sessionId, - timestamp: message.timestamp, - event: { - type: "tool_call", - tool, - args: structuredStream.args, - itemId, - parentItemId, - turnId, - }, - }); - } - - if (hasOwn(structuredStream, "result")) { - envelopes.push({ - sessionId, - timestamp: message.timestamp, - event: { - type: "tool_result", - tool, - result: structuredStream.result, - itemId, - parentItemId, - turnId, - status: normalizeToolStatus(readString(structuredStream.status)), - }, - }); - } else if (!hasOwn(structuredStream, "args")) { - envelopes.push({ - sessionId, - timestamp: message.timestamp, - event: { - type: "tool_call", - tool, - args: {}, - itemId, - parentItemId, - turnId, - }, - }); - } - - return envelopes; -} - -function buildProvenance(message: OrchestratorChatMessage): AgentChatEventEnvelope["provenance"] { - return { - messageId: message.id, - threadId: message.threadId ?? null, - role: message.role, - targetKind: message.target?.kind ?? null, - sourceSessionId: message.sourceSessionId ?? null, - attemptId: message.attemptId ?? null, - stepKey: message.stepKey ?? null, - laneId: message.laneId ?? null, - runId: message.runId ?? null, - }; -} - -function toStructuredEvents(message: OrchestratorChatMessage): AgentChatEventEnvelope[] | null { - const metadata = readRecord(message.metadata); - if (readString(metadata?.source) === "runtime_signal_progress") return null; - const structuredStream = readRecord(metadata?.structuredStream); - const kind = readString(structuredStream?.kind); - if (!structuredStream || !kind) return null; - - const sessionId = resolveSessionId(message, structuredStream); - const turnId = resolveTurnId(message, structuredStream, sessionId); - const provenance = buildProvenance(message); - - switch (kind) { - case "text": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "text", - text: message.content, - turnId, - itemId: resolveItemId(message, structuredStream, "text"), - }, - }]; - case "reasoning": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "reasoning", - text: message.content, - turnId, - itemId: resolveItemId(message, structuredStream, "reasoning"), - summaryIndex: typeof structuredStream.summaryIndex === "number" ? structuredStream.summaryIndex : undefined, - }, - }]; - case "tool": - return toToolEvents(message, structuredStream, sessionId, turnId).map((entry) => ({ - ...entry, - provenance, - })); - case "command": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "command", - command: readString(structuredStream.command) ?? "command", - cwd: readString(structuredStream.cwd) ?? "", - output: typeof structuredStream.output === "string" ? structuredStream.output : "", - itemId: resolveItemId(message, structuredStream, "command"), - turnId, - exitCode: typeof structuredStream.exitCode === "number" ? structuredStream.exitCode : null, - durationMs: typeof structuredStream.durationMs === "number" ? structuredStream.durationMs : null, - status: normalizeCommandStatus(readString(structuredStream.status)), - }, - }]; - case "file_change": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "file_change", - path: readString(structuredStream.path) ?? "(pending file)", - diff: typeof structuredStream.diff === "string" ? structuredStream.diff : "", - kind: - readString(structuredStream.changeKind) === "create" || readString(structuredStream.changeKind) === "delete" - ? (readString(structuredStream.changeKind) as "create" | "delete") - : "modify", - itemId: resolveItemId(message, structuredStream, "file_change"), - turnId, - status: normalizeCommandStatus(readString(structuredStream.status)), - }, - }]; - case "plan": { - const rawSteps = Array.isArray(structuredStream.steps) ? structuredStream.steps : []; - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "plan", - turnId, - explanation: readString(structuredStream.explanation), - steps: rawSteps - .map((step) => { - const record = readRecord(step); - const text = readString(record?.text); - if (!text) return null; - return { - text, - status: normalizePlanStepStatus(record?.status), - }; - }) - .filter((step): step is { text: string; status: "pending" | "in_progress" | "completed" | "failed" } => step != null), - }, - }]; - } - case "approval_request": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "approval_request", - itemId: resolveItemId(message, structuredStream, "approval"), - kind: readString(structuredStream.requestKind) === "command" || readString(structuredStream.requestKind) === "file_change" - ? (readString(structuredStream.requestKind) as "command" | "file_change") - : "tool_call", - description: readString(structuredStream.description) ?? message.content, - turnId, - detail: structuredStream.detail, - }, - }]; - case "activity": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "activity", - activity: normalizeActivity(readString(structuredStream.activity)), - detail: readString(structuredStream.detail) ?? undefined, - turnId, - }, - }]; - case "step_boundary": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "step_boundary", - stepNumber: typeof structuredStream.stepNumber === "number" ? structuredStream.stepNumber : 1, - turnId, - }, - }]; - case "user_message": - if (readString(structuredStream.internalKind) === "coordinator_runtime_prompt") return []; - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "user_message", - text: readString(structuredStream.text) ?? message.content, - displayText: readString(structuredStream.displayText) ?? undefined, - turnId, - deliveryState: normalizeDeliveryState(message.deliveryState), - attachments: Array.isArray(structuredStream.attachments) - ? structuredStream.attachments - .map((entry) => { - const record = readRecord(entry); - const path = readString(record?.path); - const type = readString(record?.type); - if (!path || (type !== "file" && type !== "image")) return null; - return { path, type }; - }) - .filter((entry): entry is { path: string; type: "file" | "image" } => entry != null) - : undefined, - }, - }]; - case "status": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "status", - turnStatus: normalizeTurnStatus(readString(structuredStream.status)), - turnId, - message: readString(structuredStream.message) ?? undefined, - }, - }]; - case "delegation_state": { - const contract = readDelegationContract(structuredStream.contract); - if (!contract) return null; - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "delegation_state", - contract, - turnId, - message: readString(structuredStream.message) ?? undefined, - }, - }]; - } - case "done": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "done", - turnId: turnId ?? `${sessionId}:done:${message.id}`, - status: normalizeDoneStatus(readString(structuredStream.status)), - model: readString(structuredStream.model) ?? undefined, - modelId: (readString(structuredStream.modelId) ?? undefined) as ModelId | undefined, - usage: (() => { - const usageRecord = readRecord(structuredStream.usage); - if (!usageRecord) return undefined; - return { - inputTokens: typeof usageRecord.inputTokens === "number" ? usageRecord.inputTokens : null, - outputTokens: typeof usageRecord.outputTokens === "number" ? usageRecord.outputTokens : null, - }; - })(), - }, - }]; - case "error": - return [{ - sessionId, - timestamp: message.timestamp, - provenance, - event: { - type: "error", - message: readString(structuredStream.message) ?? message.content, - turnId, - itemId: resolveItemId(message, structuredStream, "error"), - errorInfo: resolveErrorInfo(structuredStream.errorInfo), - }, - }]; - default: - return null; - } -} - -function toFallbackEvent(message: OrchestratorChatMessage): AgentChatEventEnvelope | null { - const metadata = readRecord(message.metadata); - if (readString(metadata?.source) === "runtime_signal_progress") return null; - const sessionId = resolveSessionId(message, null); - if (message.role === "user") { - return { - sessionId, - timestamp: message.timestamp, - provenance: buildProvenance(message), - event: { - type: "user_message", - text: message.content, - turnId: `${sessionId}:user:${message.id}`, - deliveryState: normalizeDeliveryState(message.deliveryState), - }, - }; - } - - if (!message.content.trim().length) return null; - if (looksLikeLowSignalNoise(message.content)) return null; - - return { - sessionId, - timestamp: message.timestamp, - provenance: buildProvenance(message), - event: { - type: "text", - text: message.content, - itemId: `${message.id}:text`, - }, - }; -} - -export function adaptMissionThreadMessagesToAgentEvents(messages: OrchestratorChatMessage[]): AgentChatEventEnvelope[] { - const sortedMessages = [...messages] - .map((message, index) => ({ message, index })) - .sort((a, b) => { - const delta = Date.parse(a.message.timestamp) - Date.parse(b.message.timestamp); - return Number.isFinite(delta) && delta !== 0 ? delta : a.index - b.index; - }); - - const events: AgentChatEventEnvelope[] = []; - for (const { message } of sortedMessages) { - const structuredEvents = toStructuredEvents(message); - if (structuredEvents) { - events.push(...structuredEvents); - continue; - } - const fallbackEvent = toFallbackEvent(message); - if (fallbackEvent) events.push(fallbackEvent); - } - - const pendingUserEventsBySession = new Map<string, number[]>(); - for (let index = 0; index < events.length; index += 1) { - const envelope = events[index]; - if (!envelope) continue; - if (envelope.event.type === "user_message") { - const pending = pendingUserEventsBySession.get(envelope.sessionId) ?? []; - pending.push(index); - pendingUserEventsBySession.set(envelope.sessionId, pending); - continue; - } - if (envelope.event.type === "step_boundary") continue; - const pending = pendingUserEventsBySession.get(envelope.sessionId); - if (!pending?.length) continue; - for (const userIndex of pending) { - const userEnvelope = events[userIndex]; - if (!userEnvelope || userEnvelope.event.type !== "user_message") continue; - events[userIndex] = { - ...userEnvelope, - event: { - ...userEnvelope.event, - processed: true, - }, - }; - } - pendingUserEventsBySession.delete(envelope.sessionId); - } - - return events; -} diff --git a/apps/desktop/src/renderer/components/missions/missionWorkerPresentation.test.ts b/apps/desktop/src/renderer/components/missions/missionWorkerPresentation.test.ts deleted file mode 100644 index f521595f5..000000000 --- a/apps/desktop/src/renderer/components/missions/missionWorkerPresentation.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MissionRunViewWorkerSummary } from "../../../shared/types"; -import { - selectCurrentMissionWorkers, - sortMissionWorkers, -} from "./missionWorkerPresentation"; - -function makeWorker( - overrides: Partial<MissionRunViewWorkerSummary> = {}, -): MissionRunViewWorkerSummary { - return { - attemptId: "attempt-1", - stepId: "step-1", - stepKey: "implement-feature", - stepTitle: "Implement feature", - laneId: "lane-1", - sessionId: "session-1", - executorKind: "agent", - state: "working", - status: "active", - lastHeartbeatAt: "2026-05-07T10:00:00.000Z", - completedAt: null, - ...overrides, - }; -} - -describe("selectCurrentMissionWorkers", () => { - it("collapses retry attempts to the current worker for each step", () => { - const current = makeWorker({ - attemptId: "attempt-current", - sessionId: "session-current", - status: "active", - state: "working", - lastHeartbeatAt: "2026-05-07T10:03:00.000Z", - }); - const oldFailed = makeWorker({ - attemptId: "attempt-old", - sessionId: "session-old", - status: "failed", - state: "failed", - completedAt: "2026-05-07T10:04:00.000Z", - lastHeartbeatAt: null, - }); - const otherStep = makeWorker({ - attemptId: "attempt-other", - stepId: "step-2", - stepKey: "test-feature", - stepTitle: "Test feature", - status: "completed", - state: "completed", - completedAt: "2026-05-07T10:02:00.000Z", - }); - - expect(selectCurrentMissionWorkers([oldFailed, current, otherStep])).toEqual([current, otherStep]); - }); - - it("keeps the newest failed attempt when a step has no active retry", () => { - const olderFailed = makeWorker({ - attemptId: "attempt-older", - status: "failed", - state: "failed", - completedAt: "2026-05-07T10:01:00.000Z", - }); - const latestFailed = makeWorker({ - attemptId: "attempt-latest", - status: "failed", - state: "failed", - completedAt: "2026-05-07T10:02:00.000Z", - }); - - expect(selectCurrentMissionWorkers([olderFailed, latestFailed])).toEqual([latestFailed]); - }); -}); - -describe("sortMissionWorkers", () => { - it("orders actionable workers before completed and failed workers", () => { - const failed = makeWorker({ - attemptId: "failed", - stepId: "failed-step", - stepTitle: "Failed", - status: "failed", - state: "failed", - completedAt: "2026-05-07T10:05:00.000Z", - }); - const active = makeWorker({ - attemptId: "active", - stepId: "active-step", - stepTitle: "Active", - status: "active", - state: "working", - }); - const completed = makeWorker({ - attemptId: "completed", - stepId: "completed-step", - stepTitle: "Completed", - status: "completed", - state: "completed", - completedAt: "2026-05-07T10:06:00.000Z", - }); - - expect(sortMissionWorkers([failed, completed, active]).map((worker) => worker.attemptId)).toEqual([ - "active", - "completed", - "failed", - ]); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/missionWorkerPresentation.ts b/apps/desktop/src/renderer/components/missions/missionWorkerPresentation.ts deleted file mode 100644 index 33eb84ab5..000000000 --- a/apps/desktop/src/renderer/components/missions/missionWorkerPresentation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { MissionRunViewWorkerSummary } from "../../../shared/types"; - -const WORKER_STATUS_RANK: Record<MissionRunViewWorkerSummary["status"], number> = { - active: 0, - blocked: 1, - idle: 2, - completed: 3, - failed: 4, -}; - -function workerStepIdentity(worker: MissionRunViewWorkerSummary): string | null { - return worker.stepId ?? worker.stepKey ?? null; -} - -function workerRecency(worker: MissionRunViewWorkerSummary): number { - const stamps = [worker.completedAt, worker.lastHeartbeatAt] - .map((stamp) => (stamp ? Date.parse(stamp) : Number.NaN)) - .filter((stamp) => Number.isFinite(stamp)); - return stamps.length ? Math.max(...stamps) : 0; -} - -function compareCurrentWorkerCandidate( - left: MissionRunViewWorkerSummary, - right: MissionRunViewWorkerSummary, -): number { - const leftRank = WORKER_STATUS_RANK[left.status] ?? 5; - const rightRank = WORKER_STATUS_RANK[right.status] ?? 5; - if (leftRank !== rightRank) return leftRank - rightRank; - return workerRecency(right) - workerRecency(left); -} - -export function selectCurrentMissionWorkers( - workers: MissionRunViewWorkerSummary[], -): MissionRunViewWorkerSummary[] { - const byStep = new Map<string, MissionRunViewWorkerSummary>(); - const ungrouped: MissionRunViewWorkerSummary[] = []; - - for (const worker of workers) { - const stepIdentity = workerStepIdentity(worker); - if (!stepIdentity) { - ungrouped.push(worker); - continue; - } - const existing = byStep.get(stepIdentity); - if (!existing || compareCurrentWorkerCandidate(worker, existing) < 0) { - byStep.set(stepIdentity, worker); - } - } - - return [...byStep.values(), ...ungrouped]; -} - -export function sortMissionWorkers( - workers: MissionRunViewWorkerSummary[], -): MissionRunViewWorkerSummary[] { - return [...workers].sort((left, right) => { - const rankDiff = - (WORKER_STATUS_RANK[left.status] ?? 5) - (WORKER_STATUS_RANK[right.status] ?? 5); - if (rankDiff !== 0) return rankDiff; - const recencyDiff = workerRecency(right) - workerRecency(left); - if (recencyDiff !== 0) return recencyDiff; - return (left.stepTitle ?? left.stepKey ?? "").localeCompare(right.stepTitle ?? right.stepKey ?? ""); - }); -} diff --git a/apps/desktop/src/renderer/components/missions/useMissionPolling.ts b/apps/desktop/src/renderer/components/missions/useMissionPolling.ts deleted file mode 100644 index b792c4f63..000000000 --- a/apps/desktop/src/renderer/components/missions/useMissionPolling.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { useEffect, useRef, useCallback } from "react"; - -/** - * Polling coordinator for the missions UI. - * - * Instead of each component running its own `setInterval`, they all register - * their refresh callbacks here. A single master timer (2 s tick) is shared - * across the page; on each tick it checks which callbacks are due and - * dispatches them. This collapses 4+ independent intervals into one timer - * and avoids IPC call storms. - * - * Usage: - * useMissionPolling(refreshMyData, 10_000, enabled); - * - * The hook handles: - * - Running the callback immediately on mount (or when it changes) - * - Firing the callback every `intervalMs` while `enabled` is true - * - Skipping ticks when the document is hidden (background tab) - * - Cleanup on unmount - */ - -// ── Shared coordinator singleton ────────────────────────────────────────── - -type Registration = { - id: number; - callback: () => void; - intervalMs: number; - lastFiredAt: number; -}; - -let nextId = 0; -const registrations = new Map<number, Registration>(); -let masterTimer: ReturnType<typeof setInterval> | null = null; - -const TICK_MS = 2_000; // Master tick interval - -function startMaster() { - if (masterTimer !== null) return; - masterTimer = setInterval(() => { - // Skip entirely when the page is backgrounded - if (typeof document !== "undefined" && document.visibilityState === "hidden") return; - - const now = Date.now(); - for (const reg of registrations.values()) { - if (now - reg.lastFiredAt >= reg.intervalMs) { - reg.lastFiredAt = now; - try { - reg.callback(); - } catch { - // Swallow errors from individual callbacks - } - } - } - }, TICK_MS); -} - -function stopMasterIfEmpty() { - if (registrations.size === 0 && masterTimer !== null) { - clearInterval(masterTimer); - masterTimer = null; - } -} - -function register(callback: () => void, intervalMs: number): number { - const id = nextId++; - registrations.set(id, { - id, - callback, - intervalMs, - lastFiredAt: Date.now(), // will fire after the first interval elapses - }); - startMaster(); - return id; -} - -function unregister(id: number) { - registrations.delete(id); - stopMasterIfEmpty(); -} - -function updateCallback(id: number, callback: () => void) { - const reg = registrations.get(id); - if (reg) { - reg.callback = callback; - } -} - -// ── React hook ────────────────────────────────────────────────────────────── - -/** - * Register a polling callback with the shared coordinator. - * - * @param callback The function to call on each tick. Can be async (fires - * fire-and-forget). Wrap with `useCallback` to avoid - * unnecessary re-registrations. - * @param intervalMs How often (in ms) the callback should fire. - * @param enabled Pass false to temporarily suspend polling (e.g. when the - * tab or panel is not visible). Default: true. - */ -export function useMissionPolling( - callback: () => void, - intervalMs: number, - enabled = true, -): void { - const regIdRef = useRef<number | null>(null); - const callbackRef = useRef(callback); - - // Keep callbackRef up-to-date so the coordinator always calls the latest version - useEffect(() => { - callbackRef.current = callback; - if (regIdRef.current !== null) { - updateCallback(regIdRef.current, () => callbackRef.current()); - } - }, [callback]); - - useEffect(() => { - if (!enabled) { - // If we were registered, unregister - if (regIdRef.current !== null) { - unregister(regIdRef.current); - regIdRef.current = null; - } - return; - } - - // Register with the coordinator - regIdRef.current = register(() => callbackRef.current(), intervalMs); - - return () => { - if (regIdRef.current !== null) { - unregister(regIdRef.current); - regIdRef.current = null; - } - }; - }, [intervalMs, enabled]); -} - -/** - * Trigger an immediate out-of-band refresh for a specific registration. - * Useful for event-driven refreshes (e.g. orchestrator events) that should - * also reset the polling timer so we don't double-fire. - * - * Returns a stable `fireNow` function that invokes the callback immediately - * and resets the last-fired timestamp. - */ -export function useMissionPollingImmediate( - callback: () => void, - intervalMs: number, - enabled = true, -): { fireNow: () => void } { - const regIdRef = useRef<number | null>(null); - const callbackRef = useRef(callback); - - useEffect(() => { - callbackRef.current = callback; - if (regIdRef.current !== null) { - updateCallback(regIdRef.current, () => callbackRef.current()); - } - }, [callback]); - - useEffect(() => { - if (!enabled) { - if (regIdRef.current !== null) { - unregister(regIdRef.current); - regIdRef.current = null; - } - return; - } - - regIdRef.current = register(() => callbackRef.current(), intervalMs); - - return () => { - if (regIdRef.current !== null) { - unregister(regIdRef.current); - regIdRef.current = null; - } - }; - }, [intervalMs, enabled]); - - const fireNow = useCallback(() => { - // Call immediately - try { - callbackRef.current(); - } catch { - // swallow - } - // Reset the timer so we don't double-fire - if (regIdRef.current !== null) { - const reg = registrations.get(regIdRef.current); - if (reg) { - reg.lastFiredAt = Date.now(); - } - } - }, []); - - return { fireNow }; -} diff --git a/apps/desktop/src/renderer/components/missions/useMissionRunView.ts b/apps/desktop/src/renderer/components/missions/useMissionRunView.ts deleted file mode 100644 index d2af559e4..000000000 --- a/apps/desktop/src/renderer/components/missions/useMissionRunView.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { MissionRunView } from "../../../shared/types"; -import { useMissionPolling } from "./useMissionPolling"; - -const runViewRequestCache = new Map<string, Promise<MissionRunView | null>>(); -const RUN_VIEW_SAFETY_POLL_MS = 45_000; - -function getRunViewCoalesced(args: { - requestKey: string; - missionId: string; - runId: string | null; -}): Promise<MissionRunView | null> { - const cached = runViewRequestCache.get(args.requestKey); - if (cached) return cached; - - const request = window.ade.missions.getRunView({ - missionId: args.missionId, - runId: args.runId, - }); - runViewRequestCache.set(args.requestKey, request); - void request - .finally(() => { - if (runViewRequestCache.get(args.requestKey) === request) { - runViewRequestCache.delete(args.requestKey); - } - }) - .catch(() => undefined); - return request; -} - -export function useMissionRunView(missionId: string | null, runId: string | null, active = true) { - const [runView, setRunView] = useState<MissionRunView | null>(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - const activeRequestKeyRef = useRef(""); - const requestSeqRef = useRef(0); - const inFlightRef = useRef(false); - const pendingRefreshRef = useRef(false); - - const resolvedMissionId = String(missionId ?? "").trim(); - const resolvedRunId = String(runId ?? "").trim(); - const requestKey = `${resolvedMissionId}::${resolvedRunId}`; - - useEffect(() => { - activeRequestKeyRef.current = requestKey; - inFlightRef.current = false; - pendingRefreshRef.current = false; - setRunView(null); - setError(null); - setLoading(Boolean(resolvedMissionId.length)); - }, [requestKey, resolvedMissionId]); - - const refresh = useCallback(async () => { - if (!resolvedMissionId.length) { - setRunView(null); - setLoading(false); - setError(null); - return; - } - if (inFlightRef.current) { - pendingRefreshRef.current = true; - return; - } - inFlightRef.current = true; - const requestSeq = ++requestSeqRef.current; - const currentRequestKey = requestKey; - try { - setLoading(true); - const next = await getRunViewCoalesced({ - requestKey: currentRequestKey, - missionId: resolvedMissionId, - runId: runId ?? null, - }); - if (requestSeq !== requestSeqRef.current || activeRequestKeyRef.current !== currentRequestKey) { - return; - } - setRunView(next); - setError(null); - } catch (err) { - if (requestSeq !== requestSeqRef.current || activeRequestKeyRef.current !== currentRequestKey) { - return; - } - setError(err instanceof Error ? err.message : String(err)); - } finally { - inFlightRef.current = false; - if (requestSeq === requestSeqRef.current && activeRequestKeyRef.current === currentRequestKey) { - setLoading(false); - } - if (pendingRefreshRef.current && activeRequestKeyRef.current === currentRequestKey) { - pendingRefreshRef.current = false; - void refresh(); - } - } - }, [requestKey, resolvedMissionId, runId]); - - useMissionPolling( - () => { - void refresh(); - }, - RUN_VIEW_SAFETY_POLL_MS, - active && Boolean(resolvedMissionId.length), - ); - - useEffect(() => { - if (!active) return; - if (!resolvedMissionId.length) return; - const subscriptionKey = requestKey; - const unsubscribe = window.ade.missions.subscribeRunView( - { missionId: resolvedMissionId, runId: runId ?? null }, - (next) => { - if (activeRequestKeyRef.current !== subscriptionKey) return; - setRunView(next); - setLoading(false); - setError(null); - }, - ); - return () => { - pendingRefreshRef.current = false; - unsubscribe(); - }; - }, [active, requestKey, resolvedMissionId, runId]); - - return { runView, loading, error, refresh }; -} diff --git a/apps/desktop/src/renderer/components/missions/useMissionsStore.test.ts b/apps/desktop/src/renderer/components/missions/useMissionsStore.test.ts deleted file mode 100644 index ca9d16de0..000000000 --- a/apps/desktop/src/renderer/components/missions/useMissionsStore.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -/* @vitest-environment jsdom */ - -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { initialMissionsState, useMissionsStore } from "./useMissionsStore"; -import type { MissionDashboardSnapshot, MissionDetail, MissionIntervention, MissionSummary } from "../../../shared/types"; - -function makeMission(id: string, status: MissionSummary["status"]): MissionSummary { - return { - id, - title: `Mission ${id}`, - prompt: `Prompt ${id}`, - status, - priority: "normal", - executionMode: "local", - laneId: null, - laneName: null, - missionLaneId: null, - missionLaneName: null, - resultLaneId: null, - resultLaneName: null, - totalSteps: 0, - completedSteps: 0, - openInterventions: 0, - artifactCount: 0, - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - startedAt: null, - completedAt: null, - lastError: null, - outcomeSummary: null, - targetMachineId: null, - }; -} - -function makeDashboard(): MissionDashboardSnapshot { - return { - active: [], - recent: [], - weekly: { - missions: 1, - successRate: 1, - avgDurationMs: 120_000, - totalCostUsd: 0, - }, - }; -} - -function makeMissionDetail(id: string, status: MissionSummary["status"]): MissionDetail { - return { - ...makeMission(id, status), - steps: [], - events: [], - artifacts: [], - interventions: [], - }; -} - -function makeManualInputIntervention(missionId: string): MissionIntervention { - return { - id: "intervention-1", - missionId, - interventionType: "manual_input", - status: "open", - resolutionKind: null, - title: "Planner question ready", - body: "Should this be documentation-only?", - requestedAction: "Answer the planning question.", - resolutionNote: null, - laneId: null, - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - resolvedAt: null, - metadata: { reasonCode: "planner_natural_question" }, - }; -} - -describe("useMissionsStore", () => { - beforeEach(() => { - vi.clearAllMocks(); - useMissionsStore.setState({ ...initialMissionsState }); - window.ade = { - missions: { - list: vi.fn(async () => [makeMission("mission-1", "completed")]), - getDashboard: vi.fn(async () => makeDashboard()), - }, - } as any; - }); - - it("can refresh the mission list and dashboard together for manual refresh", async () => { - await useMissionsStore.getState().refreshMissionList({ - preserveSelection: true, - refreshDashboard: true, - }); - - expect(window.ade.missions.list).toHaveBeenCalledWith({ limit: 300 }); - expect(window.ade.missions.getDashboard).toHaveBeenCalledTimes(1); - expect(useMissionsStore.getState().missions[0]?.status).toBe("completed"); - expect(useMissionsStore.getState().dashboard?.active).toEqual([]); - }); - - it("keeps dashboard refresh opt-in so initial list loading stays staged", async () => { - await useMissionsStore.getState().refreshMissionList({ preserveSelection: true }); - - expect(window.ade.missions.list).toHaveBeenCalledWith({ limit: 300 }); - expect(window.ade.missions.getDashboard).not.toHaveBeenCalled(); - }); - - it("refreshes the selected mission summary while preserving hydrated detail fields", async () => { - useMissionsStore.setState({ - selectedMissionId: "mission-1", - selectedMission: makeMissionDetail("mission-1", "in_progress"), - }); - - await useMissionsStore.getState().refreshMissionList({ preserveSelection: true }); - - const selected = useMissionsStore.getState().selectedMission; - expect(selected?.status).toBe("completed"); - expect(selected?.completedAt).toBe(null); - expect(selected?.interventions).toEqual([]); - }); - - it("rehydrates selected mission detail when open intervention counts diverge", async () => { - const summaryWithIntervention = { - ...makeMission("mission-1", "intervention_required"), - openInterventions: 1, - }; - const detailWithIntervention = { - ...makeMissionDetail("mission-1", "intervention_required"), - openInterventions: 1, - interventions: [makeManualInputIntervention("mission-1")], - }; - window.ade.missions.list = vi.fn(async () => [summaryWithIntervention]); - window.ade.missions.getFullMissionView = vi.fn(async () => ({ - mission: detailWithIntervention, - runGraph: null, - artifacts: [], - checkpoints: [], - dashboard: null, - })); - useMissionsStore.setState({ - selectedMissionId: "mission-1", - selectedMission: makeMissionDetail("mission-1", "planning"), - }); - - await useMissionsStore.getState().refreshMissionList({ preserveSelection: true }); - - expect(window.ade.missions.getFullMissionView).toHaveBeenCalledWith({ missionId: "mission-1" }); - expect(useMissionsStore.getState().selectedMission?.interventions).toHaveLength(1); - }); - - it("rehydrates selected mission detail when an open intervention changes without changing the count", async () => { - const staleDetail = { - ...makeMissionDetail("mission-1", "intervention_required"), - openInterventions: 1, - interventions: [makeManualInputIntervention("mission-1")], - }; - const summaryWithNewerIntervention = { - ...makeMission("mission-1", "intervention_required"), - openInterventions: 1, - updatedAt: "2026-05-07T00:01:00.000Z", - }; - const phaseApprovalIntervention = { - ...makeManualInputIntervention("mission-1"), - id: "intervention-2", - interventionType: "phase_approval" as const, - title: "Approve transition from Planning phase", - body: "Approve Planning output.", - metadata: { source: "phase_approval_gate" }, - updatedAt: "2026-05-07T00:01:00.000Z", - }; - const refreshedDetail = { - ...makeMissionDetail("mission-1", "intervention_required"), - openInterventions: 1, - updatedAt: "2026-05-07T00:01:00.000Z", - interventions: [phaseApprovalIntervention], - }; - window.ade.missions.list = vi.fn(async () => [summaryWithNewerIntervention]); - window.ade.missions.getFullMissionView = vi.fn(async () => ({ - mission: refreshedDetail, - runGraph: null, - artifacts: [], - checkpoints: [], - dashboard: null, - })); - useMissionsStore.setState({ - selectedMissionId: "mission-1", - selectedMission: staleDetail, - }); - - await useMissionsStore.getState().refreshMissionList({ preserveSelection: true }); - - expect(window.ade.missions.getFullMissionView).toHaveBeenCalledWith({ missionId: "mission-1" }); - expect(useMissionsStore.getState().selectedMission?.interventions[0]?.id).toBe("intervention-2"); - }); - - it("hydrates Smart Budget mission settings from project config", async () => { - window.ade.projectConfig = { - get: vi.fn(async () => ({ - shared: {}, - local: { - ai: { - orchestrator: { - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 12, - weeklyThresholdUsd: 90, - modelDowngradeThresholdPct: 60, - }, - }, - }, - }, - effective: { ai: { orchestrator: {} } }, - })), - } as any; - - await useMissionsStore.getState().loadMissionSettings(); - - expect(useMissionsStore.getState().missionSettingsDraft.smartBudget).toEqual({ - enabled: true, - fiveHourThresholdUsd: 12, - weeklyThresholdUsd: 90, - modelDowngradeThresholdPct: 60, - }); - }); - - it("persists Smart Budget mission settings to local project config", async () => { - const baseSnapshot = { - shared: {}, - local: { ai: { orchestrator: {}, permissions: {} } }, - effective: { ai: { orchestrator: {}, permissions: {} } }, - }; - const save = vi.fn(async (candidate) => ({ - ...baseSnapshot, - ...candidate, - effective: candidate.local, - })); - window.ade.projectConfig = { - get: vi.fn(async () => baseSnapshot), - save, - } as any; - useMissionsStore.getState().setMissionSettingsDraft((draft) => ({ - ...draft, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 18, - weeklyThresholdUsd: 140, - fiveHourHardStopPercent: 70, - }, - })); - - await useMissionsStore.getState().saveMissionSettings(); - - expect(save).toHaveBeenCalledWith(expect.objectContaining({ - local: expect.objectContaining({ - ai: expect.objectContaining({ - orchestrator: expect.objectContaining({ - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 18, - weeklyThresholdUsd: 140, - fiveHourHardStopPercent: 70, - }, - }), - }), - }), - })); - expect(useMissionsStore.getState().missionSettingsNotice).toBe("Mission settings saved to .ade/local.yaml."); - }); -}); diff --git a/apps/desktop/src/renderer/components/missions/useMissionsStore.ts b/apps/desktop/src/renderer/components/missions/useMissionsStore.ts deleted file mode 100644 index 5b75c6944..000000000 --- a/apps/desktop/src/renderer/components/missions/useMissionsStore.ts +++ /dev/null @@ -1,844 +0,0 @@ -import React, { createContext, useContext, type ReactNode } from "react"; -import { useStore } from "zustand"; -import { createStore, type StoreApi } from "zustand/vanilla"; -import type { StateCreator } from "zustand"; -import type { - MissionDetail, - MissionSummary, - MissionDashboardSnapshot, - MissionPermissionConfig, - ModelConfig, - GetModelCapabilitiesResult, - OrchestratorArtifact, - OrchestratorChatTarget, - OrchestratorPromptInspector, - OrchestratorRunGraph, - OrchestratorWorkerCheckpoint, - ProjectConfigSnapshot, - SmartBudgetConfig, -} from "../../../shared/types"; -import { - DEFAULT_MISSION_SETTINGS_DRAFT, - DEFAULT_SMART_BUDGET, - DEFAULT_ORCHESTRATOR_MODEL, - DEFAULT_PERMISSION_CONFIG, - isRecord, - readString, - toPlannerProvider, - plannerProviderToModelConfig, - toTeammatePlanMode, - toCliMode, - toCliSandboxPermissions, - toInProcessMode, - type WorkspaceTab, - type MissionListViewMode, - type MissionSettingsDraft, -} from "./missionHelpers"; - -/* ════════════════════ TYPES ════════════════════ */ - -export type OrchestratorCheckpointStatus = { - savedAt: string; - turnCount: number; - compactionCount: number; -}; - -export type MissionContextMenuState = { - mission: MissionSummary; - x: number; - y: number; -} | null; - -export type MissionAttentionToast = { - id: string; - missionTitle: string; - message: string; - severity: "warning" | "error"; - missionId: string; -}; - -/* ════════════════════ STORE STATE ════════════════════ */ - -export type MissionsState = { - /* ── Core domain state ── */ - missions: MissionSummary[]; - selectedMissionId: string | null; - selectedMission: MissionDetail | null; - runGraph: OrchestratorRunGraph | null; - checkpointStatus: OrchestratorCheckpointStatus | null; - dashboard: MissionDashboardSnapshot | null; - - /* ── Loading / error ── */ - loading: boolean; - error: string | null; - refreshing: boolean; - - /* ── Run actions ── */ - runBusy: boolean; - - /* ── Settings ── */ - missionSettingsOpen: boolean; - missionSettingsBusy: boolean; - missionSettingsError: string | null; - missionSettingsNotice: string | null; - missionSettingsSnapshot: ProjectConfigSnapshot | null; - missionSettingsDraft: MissionSettingsDraft; - - /* ── UI state ── */ - activeTab: WorkspaceTab; - planSubview: "board" | "dag"; - searchFilter: string; - missionListView: MissionListViewMode; - missionContextMenu: MissionContextMenuState; - selectedStepId: string | null; - chatJumpTarget: OrchestratorChatTarget | null; - logsFocusInterventionId: string | null; - activityPanelMode: "signal" | "logs"; - orchestratorArtifacts: OrchestratorArtifact[]; - workerCheckpoints: OrchestratorWorkerCheckpoint[]; - modelCapabilities: GetModelCapabilitiesResult | null; - - /* ── Prompt inspector ── */ - coordinatorPromptInspector: OrchestratorPromptInspector | null; - coordinatorPromptLoading: boolean; - coordinatorPromptError: string | null; - workerPromptInspector: OrchestratorPromptInspector | null; - workerPromptLoading: boolean; - workerPromptError: string | null; - - /* ── Steering ── */ - steerBusy: boolean; - - /* ── Intervention modal ── */ - activeInterventionId: string | null; - - /* ── Attention toasts ── */ - attentionToasts: MissionAttentionToast[]; - - /* ── Progress tracking ── */ - originalStepCount: number | null; - - /* ── Manage mission dialog ── */ - manageMission: MissionSummary | null; - manageMissionOpen: boolean; - manageMissionCleanupLanes: boolean; - manageMissionBusy: boolean; - manageMissionError: string | null; - - /* ── Lane cleanup ── */ - cleanupBusy: boolean; -}; - -/* ════════════════════ STORE ACTIONS ════════════════════ */ - -export type MissionsActions = { - /* ── Simple setters ── */ - setMissions: (missions: MissionSummary[]) => void; - setSelectedMissionId: (id: string | null) => void; - setSelectedMission: (mission: MissionDetail | null) => void; - setRunGraph: (graph: OrchestratorRunGraph | null) => void; - setCheckpointStatus: (status: OrchestratorCheckpointStatus | null) => void; - setDashboard: (dashboard: MissionDashboardSnapshot | null) => void; - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setRefreshing: (refreshing: boolean) => void; - setRunBusy: (busy: boolean) => void; - setActiveTab: (tab: WorkspaceTab) => void; - setPlanSubview: (view: "board" | "dag") => void; - setSearchFilter: (filter: string) => void; - setMissionListView: (view: MissionListViewMode) => void; - setMissionContextMenu: (menu: MissionContextMenuState) => void; - setSelectedStepId: (id: string | null) => void; - setChatJumpTarget: (target: OrchestratorChatTarget | null) => void; - setLogsFocusInterventionId: (id: string | null) => void; - setActivityPanelMode: (mode: "signal" | "logs") => void; - setOrchestratorArtifacts: (artifacts: OrchestratorArtifact[]) => void; - setWorkerCheckpoints: (checkpoints: OrchestratorWorkerCheckpoint[]) => void; - setModelCapabilities: (caps: GetModelCapabilitiesResult | null) => void; - setActiveInterventionId: (id: string | null) => void; - setAttentionToasts: (toasts: MissionAttentionToast[]) => void; - setOriginalStepCount: (count: number | null) => void; - setSteerBusy: (busy: boolean) => void; - setCleanupBusy: (busy: boolean) => void; - - /* ── Settings actions ── */ - setMissionSettingsOpen: (open: boolean) => void; - setMissionSettingsBusy: (busy: boolean) => void; - setMissionSettingsError: (error: string | null) => void; - setMissionSettingsNotice: (notice: string | null) => void; - setMissionSettingsSnapshot: (snapshot: ProjectConfigSnapshot | null) => void; - setMissionSettingsDraft: (draft: MissionSettingsDraft | ((prev: MissionSettingsDraft) => MissionSettingsDraft)) => void; - - /* ── Prompt inspector ── */ - setCoordinatorPromptInspector: (inspector: OrchestratorPromptInspector | null) => void; - setCoordinatorPromptLoading: (loading: boolean) => void; - setCoordinatorPromptError: (error: string | null) => void; - setWorkerPromptInspector: (inspector: OrchestratorPromptInspector | null) => void; - setWorkerPromptLoading: (loading: boolean) => void; - setWorkerPromptError: (error: string | null) => void; - - /* ── Manage mission dialog ── */ - setManageMission: (mission: MissionSummary | null) => void; - setManageMissionOpen: (open: boolean) => void; - setManageMissionCleanupLanes: (cleanup: boolean) => void; - setManageMissionBusy: (busy: boolean) => void; - setManageMissionError: (error: string | null) => void; - - /* ── Compound actions (IPC calls) ── */ - refreshMissionList: (opts?: { preserveSelection?: boolean; silent?: boolean; refreshDashboard?: boolean }) => Promise<void>; - loadDashboard: () => Promise<void>; - loadMissionDetail: (missionId: string) => Promise<void>; - loadOrchestratorGraph: (missionId: string) => Promise<void>; - loadRunArtifacts: (missionId: string, runId: string | null) => Promise<void>; - loadMissionSettings: () => Promise<void>; - saveMissionSettings: () => Promise<void>; - applyMissionSettingsSnapshot: (snapshot: ProjectConfigSnapshot) => void; - - /** Consolidated mission selection via single getFullMissionView IPC call. */ - selectMission: (missionId: string | null) => Promise<void>; - - /* ── Event subscription lifecycle ── */ - /** Start event subscriptions and debounced refresh timers. Returns cleanup fn. */ - initEventSubscriptions: () => () => void; - - /* ── Attention toast management (timers owned by store, not components) ── */ - addAttentionToast: (message: string, severity: "warning" | "error", missionTitle: string, missionId: string) => void; - dismissAttentionToast: (id: string) => void; - /** Cleanup all toast timers (call on unmount) */ - cleanupToastTimers: () => void; - - /* ── Selection reset ── */ - clearSelection: () => void; -}; - -export type MissionsStore = MissionsState & MissionsActions; - -/* ════════════════════ INITIAL STATE ════════════════════ */ - -export const initialMissionsState: MissionsState = { - missions: [], - selectedMissionId: null, - selectedMission: null, - runGraph: null, - checkpointStatus: null, - dashboard: null, - loading: true, - error: null, - refreshing: false, - runBusy: false, - missionSettingsOpen: false, - missionSettingsBusy: false, - missionSettingsError: null, - missionSettingsNotice: null, - missionSettingsSnapshot: null, - missionSettingsDraft: DEFAULT_MISSION_SETTINGS_DRAFT, - activeTab: "chat", - planSubview: "board", - searchFilter: "", - missionListView: "list", - missionContextMenu: null, - selectedStepId: null, - chatJumpTarget: null, - logsFocusInterventionId: null, - activityPanelMode: "signal", - orchestratorArtifacts: [], - workerCheckpoints: [], - modelCapabilities: null, - coordinatorPromptInspector: null, - coordinatorPromptLoading: false, - coordinatorPromptError: null, - workerPromptInspector: null, - workerPromptLoading: false, - workerPromptError: null, - steerBusy: false, - activeInterventionId: null, - attentionToasts: [], - originalStepCount: null, - manageMission: null, - manageMissionOpen: false, - manageMissionCleanupLanes: false, - manageMissionBusy: false, - manageMissionError: null, - cleanupBusy: false, -}; - -function isRendererVisible(): boolean { - return typeof document === "undefined" || document.visibilityState === "visible"; -} - -function buildClearedSelectionState(): Partial<MissionsState> { - return { - selectedMissionId: null, - selectedMission: null, - runGraph: null, - chatJumpTarget: null, - logsFocusInterventionId: null, - activityPanelMode: "signal", - originalStepCount: null, - orchestratorArtifacts: [], - workerCheckpoints: [], - coordinatorPromptInspector: null, - workerPromptInspector: null, - }; -} - -function readNumber(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function readOptionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function normalizeSmartBudgetConfig(value: unknown, fallback: SmartBudgetConfig = DEFAULT_SMART_BUDGET): SmartBudgetConfig { - const source = isRecord(value) ? value : fallback; - const fallbackProviderLimits = isRecord(fallback.providerLimits) - ? fallback.providerLimits as SmartBudgetConfig["providerLimits"] - : undefined; - const providerLimits = isRecord(source.providerLimits) - ? source.providerLimits as SmartBudgetConfig["providerLimits"] - : fallbackProviderLimits; - const fiveHourHardStopPercent = readOptionalNumber(source.fiveHourHardStopPercent); - const weeklyHardStopPercent = readOptionalNumber(source.weeklyHardStopPercent); - const apiKeyMaxSpendUsd = readOptionalNumber(source.apiKeyMaxSpendUsd); - const modelDowngradeThresholdPct = readOptionalNumber(source.modelDowngradeThresholdPct); - return { - enabled: typeof source.enabled === "boolean" ? source.enabled : fallback.enabled, - fiveHourThresholdUsd: readNumber(source.fiveHourThresholdUsd, fallback.fiveHourThresholdUsd), - weeklyThresholdUsd: readNumber(source.weeklyThresholdUsd, fallback.weeklyThresholdUsd), - ...(providerLimits ? { providerLimits: { ...providerLimits } } : {}), - ...(fiveHourHardStopPercent !== undefined ? { fiveHourHardStopPercent } : {}), - ...(weeklyHardStopPercent !== undefined ? { weeklyHardStopPercent } : {}), - ...(apiKeyMaxSpendUsd !== undefined ? { apiKeyMaxSpendUsd } : {}), - ...(modelDowngradeThresholdPct !== undefined ? { modelDowngradeThresholdPct } : {}), - }; -} - -/* ════════════════════ STORE CREATION ════════════════════ */ - -const createMissionsState: StateCreator<MissionsStore> = (set, get) => { - const toastTimers = new Map<string, number>(); - let missionSelectionRequestSeq = 0; - - return ({ - ...initialMissionsState, - - /* ── Simple setters ── */ - setMissions: (missions) => set({ missions }), - setSelectedMissionId: (id) => set({ selectedMissionId: id }), - setSelectedMission: (mission) => set({ selectedMission: mission }), - setRunGraph: (graph) => set({ runGraph: graph }), - setCheckpointStatus: (status) => set({ checkpointStatus: status }), - setDashboard: (dashboard) => set({ dashboard: dashboard }), - setLoading: (loading) => set({ loading }), - setError: (error) => set({ error }), - setRefreshing: (refreshing) => set({ refreshing }), - setRunBusy: (busy) => set({ runBusy: busy }), - setActiveTab: (tab) => set({ activeTab: tab }), - setPlanSubview: (view) => set({ planSubview: view }), - setSearchFilter: (filter) => set({ searchFilter: filter }), - setMissionListView: (view) => set({ missionListView: view }), - setMissionContextMenu: (menu) => set({ missionContextMenu: menu }), - setSelectedStepId: (id) => set({ selectedStepId: id }), - setChatJumpTarget: (target) => set({ chatJumpTarget: target }), - setLogsFocusInterventionId: (id) => set({ logsFocusInterventionId: id }), - setActivityPanelMode: (mode) => set({ activityPanelMode: mode }), - setOrchestratorArtifacts: (artifacts) => set({ orchestratorArtifacts: artifacts }), - setWorkerCheckpoints: (checkpoints) => set({ workerCheckpoints: checkpoints }), - setModelCapabilities: (caps) => set({ modelCapabilities: caps }), - setActiveInterventionId: (id) => set({ activeInterventionId: id }), - setAttentionToasts: (toasts) => set({ attentionToasts: toasts }), - setOriginalStepCount: (count) => set({ originalStepCount: count }), - setSteerBusy: (busy) => set({ steerBusy: busy }), - setCleanupBusy: (busy) => set({ cleanupBusy: busy }), - - /* ── Settings setters ── */ - setMissionSettingsOpen: (open) => set({ missionSettingsOpen: open }), - setMissionSettingsBusy: (busy) => set({ missionSettingsBusy: busy }), - setMissionSettingsError: (error) => set({ missionSettingsError: error }), - setMissionSettingsNotice: (notice) => set({ missionSettingsNotice: notice }), - setMissionSettingsSnapshot: (snapshot) => set({ missionSettingsSnapshot: snapshot }), - setMissionSettingsDraft: (draftOrFn) => set((state) => ({ - missionSettingsDraft: typeof draftOrFn === "function" - ? draftOrFn(state.missionSettingsDraft) - : draftOrFn, - })), - - /* ── Prompt inspector setters ── */ - setCoordinatorPromptInspector: (inspector) => set({ coordinatorPromptInspector: inspector }), - setCoordinatorPromptLoading: (loading) => set({ coordinatorPromptLoading: loading }), - setCoordinatorPromptError: (error) => set({ coordinatorPromptError: error }), - setWorkerPromptInspector: (inspector) => set({ workerPromptInspector: inspector }), - setWorkerPromptLoading: (loading) => set({ workerPromptLoading: loading }), - setWorkerPromptError: (error) => set({ workerPromptError: error }), - - /* ── Manage mission dialog setters ── */ - setManageMission: (mission) => set({ manageMission: mission }), - setManageMissionOpen: (open) => set({ manageMissionOpen: open }), - setManageMissionCleanupLanes: (cleanup) => set({ manageMissionCleanupLanes: cleanup }), - setManageMissionBusy: (busy) => set({ manageMissionBusy: busy }), - setManageMissionError: (error) => set({ manageMissionError: error }), - - /* ── Compound actions (IPC calls) ── */ - refreshMissionList: async (opts = {}) => { - const { preserveSelection = true, silent = false, refreshDashboard = false } = opts; - if (!silent) set({ refreshing: true }); - try { - const list = await window.ade.missions.list({ limit: 300 }); - let selectedDetailNeedsRefresh: string | null = null; - set((state) => { - const selectedStillExists = Boolean( - state.selectedMissionId && list.some((mission) => mission.id === state.selectedMissionId), - ); - const nextId = preserveSelection - ? (selectedStillExists ? state.selectedMissionId : null) - : (list[0]?.id ?? null); - const selectedSummary = nextId ? list.find((mission) => mission.id === nextId) ?? null : null; - const selectedMission = selectedSummary && state.selectedMission?.id === selectedSummary.id - ? { ...state.selectedMission, ...selectedSummary } - : null; - if (selectedSummary && state.selectedMission?.id === selectedSummary.id) { - const hydratedOpenInterventions = state.selectedMission.interventions.filter((entry) => entry.status === "open").length; - const selectedSummaryChangedWithOpenInterventions = - selectedSummary.openInterventions > 0 - && selectedSummary.updatedAt !== state.selectedMission.updatedAt; - if (hydratedOpenInterventions !== selectedSummary.openInterventions || selectedSummaryChangedWithOpenInterventions) { - selectedDetailNeedsRefresh = selectedSummary.id; - } - } - return { - missions: list, - error: null, - loading: false, - refreshing: false, - selectedMissionId: nextId, - selectedMission, - ...(!nextId ? buildClearedSelectionState() : {}), - }; - }); - if (refreshDashboard) { - await get().loadDashboard(); - } - if (selectedDetailNeedsRefresh && get().selectedMissionId === selectedDetailNeedsRefresh) { - await get().selectMission(selectedDetailNeedsRefresh); - } - } catch (err) { - set({ - error: err instanceof Error ? err.message : String(err), - loading: false, - refreshing: false, - }); - } - }, - - loadDashboard: async () => { - try { - const snapshot = await window.ade.missions.getDashboard(); - set({ dashboard: snapshot }); - } catch { - // Best-effort dashboard hydration. - } - }, - - loadMissionDetail: async (missionId: string) => { - const trimmed = missionId.trim(); - if (!trimmed) return; - try { - const detail = await window.ade.missions.get(trimmed); - set({ selectedMission: detail, error: null }); - } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }); - } - }, - - loadOrchestratorGraph: async (missionId: string) => { - const trimmed = missionId.trim(); - if (!trimmed) { set({ runGraph: null }); return; } - try { - const runs = await window.ade.orchestrator.listRuns({ missionId: trimmed, limit: 20 }); - const latestRun = runs[0]; - if (!latestRun) { set({ runGraph: null }); return; } - const graph = await window.ade.orchestrator.getRunGraph({ runId: latestRun.id, timelineLimit: 120 }); - set((state) => ({ - runGraph: graph, - originalStepCount: state.originalStepCount === null && graph.steps.length > 0 - ? graph.steps.length - : state.originalStepCount, - })); - } catch (err) { - set({ - error: err instanceof Error ? err.message : String(err), - runGraph: null, - }); - } - }, - - loadRunArtifacts: async (missionId: string, runId: string | null) => { - const trimmed = missionId.trim(); - if (!trimmed) { - set({ orchestratorArtifacts: [], workerCheckpoints: [] }); - return; - } - try { - const [artifacts, checkpoints] = await Promise.all([ - window.ade.orchestrator.listArtifacts({ missionId: trimmed, runId }), - window.ade.orchestrator.listWorkerCheckpoints({ missionId: trimmed, runId }), - ]); - set({ - orchestratorArtifacts: Array.isArray(artifacts) ? artifacts : [], - workerCheckpoints: Array.isArray(checkpoints) ? checkpoints : [], - }); - } catch { - set({ orchestratorArtifacts: [], workerCheckpoints: [] }); - } - }, - - applyMissionSettingsSnapshot: (snapshot: ProjectConfigSnapshot) => { - const localAi = isRecord(snapshot.local.ai) ? snapshot.local.ai : {}; - const effectiveAi = isRecord(snapshot.effective.ai) ? snapshot.effective.ai : {}; - const localOrchestrator = isRecord(localAi.orchestrator) ? localAi.orchestrator : {}; - const effectiveOrchestrator = isRecord(effectiveAi.orchestrator) ? effectiveAi.orchestrator : {}; - const localPermissions = isRecord(localAi.permissions) ? localAi.permissions : {}; - const effectivePermissions = isRecord(effectiveAi.permissions) ? effectiveAi.permissions : {}; - const localCli = isRecord(localPermissions.cli) ? localPermissions.cli : {}; - const effectiveCli = isRecord(effectivePermissions.cli) ? effectivePermissions.cli : {}; - const localInProcess = isRecord(localPermissions.inProcess) ? localPermissions.inProcess : {}; - const effectiveInProcess = isRecord(effectivePermissions.inProcess) ? effectivePermissions.inProcess : {}; - const localProviders = isRecord(localPermissions.providers) ? localPermissions.providers : {}; - const effectiveProviders = isRecord(effectivePermissions.providers) ? effectivePermissions.providers : {}; - const localOrcModel = isRecord(localOrchestrator.defaultOrchestratorModel) ? localOrchestrator.defaultOrchestratorModel : null; - const effectiveOrcModel = isRecord(effectiveOrchestrator.defaultOrchestratorModel) ? effectiveOrchestrator.defaultOrchestratorModel : null; - const effectiveSmartBudget = normalizeSmartBudgetConfig( - localOrchestrator.smartBudget ?? effectiveOrchestrator.smartBudget, - DEFAULT_SMART_BUDGET, - ); - - // Legacy fallback: derive from deprecated defaultPlannerProvider if no orchestrator model - const rawLocal = localOrchestrator as Record<string, unknown>; - const rawEffective = effectiveOrchestrator as Record<string, unknown>; - const legacyPlannerProvider = toPlannerProvider( - readString(rawLocal.defaultPlannerProvider, rawEffective.defaultPlannerProvider, "auto") - ); - - let orchestratorModel: ModelConfig; - if (localOrcModel && typeof localOrcModel.modelId === "string") { - orchestratorModel = { - modelId: localOrcModel.modelId as string, - provider: (localOrcModel.provider as import("../../../shared/types").ModelProvider) ?? undefined, - thinkingLevel: (localOrcModel.thinkingLevel as import("../../../shared/types").ThinkingLevel) ?? undefined, - }; - } else if (effectiveOrcModel && typeof effectiveOrcModel.modelId === "string") { - orchestratorModel = { - modelId: effectiveOrcModel.modelId as string, - provider: (effectiveOrcModel.provider as import("../../../shared/types").ModelProvider) ?? undefined, - thinkingLevel: (effectiveOrcModel.thinkingLevel as import("../../../shared/types").ThinkingLevel) ?? undefined, - }; - } else { - orchestratorModel = plannerProviderToModelConfig(legacyPlannerProvider); - } - - const permissionConfig: MissionPermissionConfig = { - providers: { - claude: readString(localProviders.claude, effectiveProviders.claude, "full-auto") as import("../../../shared/types").AgentChatPermissionMode, - codex: readString(localProviders.codex, effectiveProviders.codex, "default") as import("../../../shared/types").AgentChatPermissionMode, - droid: readString(localProviders.droid, effectiveProviders.droid, "full-auto") as import("../../../shared/types").AgentChatPermissionMode, - opencode: readString(localProviders.opencode, effectiveProviders.opencode, "full-auto") as import("../../../shared/types").AgentChatPermissionMode, - codexSandbox: readString(localProviders.codexSandbox, effectiveProviders.codexSandbox, "workspace-write") as "read-only" | "workspace-write" | "danger-full-access", - }, - }; - - set({ - missionSettingsSnapshot: snapshot, - missionSettingsDraft: { - defaultOrchestratorModel: orchestratorModel, - permissionConfig, - teammatePlanMode: toTeammatePlanMode( - readString(localOrchestrator.teammatePlanMode, effectiveOrchestrator.teammatePlanMode, "auto") - ), - cliMode: toCliMode(readString(localCli.mode, effectiveCli.mode, "full-auto")), - cliSandboxPermissions: toCliSandboxPermissions(readString(localCli.sandboxPermissions, effectiveCli.sandboxPermissions, "workspace-write")), - inProcessMode: toInProcessMode(readString(localInProcess.mode, effectiveInProcess.mode, "full-auto")), - smartBudget: effectiveSmartBudget, - }, - }); - }, - - loadMissionSettings: async () => { - set({ missionSettingsError: null }); - try { - const snapshot = await window.ade.projectConfig.get(); - get().applyMissionSettingsSnapshot(snapshot); - } catch (err) { - set({ missionSettingsError: err instanceof Error ? err.message : String(err) }); - } - }, - - saveMissionSettings: async () => { - set({ missionSettingsBusy: true, missionSettingsError: null, missionSettingsNotice: null }); - try { - const state = get(); - const snapshot = state.missionSettingsSnapshot ?? (await window.ade.projectConfig.get()); - const localAi = isRecord(snapshot.local.ai) ? snapshot.local.ai : {}; - const localOrchestrator = isRecord(localAi.orchestrator) ? localAi.orchestrator : {}; - const localPermissions = isRecord(localAi.permissions) ? localAi.permissions : {}; - const localCli = isRecord(localPermissions.cli) ? localPermissions.cli : {}; - const localInProcess = isRecord(localPermissions.inProcess) ? localPermissions.inProcess : {}; - const draft = state.missionSettingsDraft; - - const normalizedOrchestratorModel = draft.defaultOrchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL; - const normalizedCliMode = toCliMode(draft.cliMode); - const normalizedCliSandbox = toCliSandboxPermissions(draft.cliSandboxPermissions); - const normalizedInProcessMode = toInProcessMode(draft.inProcessMode); - - const nextOrchestrator: Record<string, unknown> = { - ...localOrchestrator, - defaultOrchestratorModel: normalizedOrchestratorModel, - teammatePlanMode: toTeammatePlanMode(draft.teammatePlanMode), - smartBudget: normalizeSmartBudgetConfig(draft.smartBudget, DEFAULT_SMART_BUDGET), - }; - delete nextOrchestrator.requirePlanReview; - delete nextOrchestrator.defaultDepthTier; - delete nextOrchestrator.default_depth_tier; - delete nextOrchestrator.defaultPlannerProvider; - - const nextCli: Record<string, unknown> = { - ...localCli, - mode: normalizedCliMode, - sandboxPermissions: normalizedCliSandbox, - }; - - const nextInProcess: Record<string, unknown> = { - ...localInProcess, - mode: normalizedInProcessMode, - }; - - const saved = await window.ade.projectConfig.save({ - shared: snapshot.shared, - local: { - ...snapshot.local, - ai: { - ...localAi, - orchestrator: nextOrchestrator, - permissions: { - ...localPermissions, - cli: nextCli, - inProcess: nextInProcess, - providers: draft.permissionConfig?.providers ?? DEFAULT_PERMISSION_CONFIG.providers, - }, - }, - }, - }); - - get().applyMissionSettingsSnapshot(saved); - set({ missionSettingsNotice: "Mission settings saved to .ade/local.yaml." }); - } catch (err) { - set({ missionSettingsError: err instanceof Error ? err.message : String(err) }); - } finally { - set({ missionSettingsBusy: false }); - } - }, - - /* ── Consolidated mission selection (VAL-ARCH-004) ── */ - selectMission: async (missionId: string | null) => { - const currentSelectedId = get().selectedMissionId; - const currentHydratedMissionId = get().selectedMission?.id ?? null; - const selectedIdChanged = missionId !== currentSelectedId; - const hydratedMissionChanged = missionId !== currentHydratedMissionId; - - // Update selected ID immediately for responsive UI when the selection actually changes. - if (selectedIdChanged) { - set({ selectedMissionId: missionId }); - } - - if (!missionId) { - get().clearSelection(); - return; - } - - if (hydratedMissionChanged) { - // Reset transient state only when the user actually switches missions. - set({ - chatJumpTarget: null, - logsFocusInterventionId: null, - activityPanelMode: "signal", - coordinatorPromptInspector: null, - workerPromptInspector: null, - }); - } - - const requestSeq = ++missionSelectionRequestSeq; - try { - const view = await window.ade.missions.getFullMissionView({ missionId }); - if (requestSeq !== missionSelectionRequestSeq || get().selectedMissionId !== missionId) { - return; - } - set((state) => ({ - selectedMission: view.mission, - runGraph: view.runGraph, - orchestratorArtifacts: Array.isArray(view.artifacts) ? view.artifacts : [], - workerCheckpoints: Array.isArray(view.checkpoints) ? view.checkpoints : [], - dashboard: view.dashboard ?? state.dashboard, - originalStepCount: - state.originalStepCount === null && view.runGraph && view.runGraph.steps.length > 0 - ? view.runGraph.steps.length - : state.originalStepCount, - error: null, - })); - } catch (err) { - if (requestSeq !== missionSelectionRequestSeq || get().selectedMissionId !== missionId) { - return; - } - set({ error: err instanceof Error ? err.message : String(err) }); - } - }, - - /* ── Event subscriptions (VAL-ARCH-007) ── */ - initEventSubscriptions: () => { - let missionEventTimer: number | null = null; - let orchestratorEventTimer: number | null = null; - - const unsubMissions = window.ade.missions.onEvent((payload) => { - if (!isRendererVisible()) return; - if (missionEventTimer !== null) window.clearTimeout(missionEventTimer); - missionEventTimer = window.setTimeout(() => { - missionEventTimer = null; - void (async () => { - await get().refreshMissionList({ preserveSelection: true, silent: true }); - await get().loadDashboard(); - const currentSelectedId = get().selectedMissionId; - if (payload.missionId && payload.missionId === currentSelectedId) { - await get().selectMission(payload.missionId); - } - })(); - }, 350); - }); - - const unsubOrchestrator = window.ade.orchestrator.onEvent((event) => { - if (!isRendererVisible()) return; - const selectedRunId = get().runGraph?.run.id ?? null; - if (selectedRunId && event.runId && event.runId !== selectedRunId) return; - if (orchestratorEventTimer !== null) window.clearTimeout(orchestratorEventTimer); - orchestratorEventTimer = window.setTimeout(() => { - orchestratorEventTimer = null; - const latestSelectedId = get().selectedMissionId; - if (!latestSelectedId) return; - const latestSelectedRunId = get().runGraph?.run.id ?? null; - if (latestSelectedRunId && event.runId && event.runId !== latestSelectedRunId) return; - void get().selectMission(latestSelectedId); - }, 650); - }); - - return () => { - if (missionEventTimer !== null) window.clearTimeout(missionEventTimer); - if (orchestratorEventTimer !== null) window.clearTimeout(orchestratorEventTimer); - unsubMissions(); - unsubOrchestrator(); - }; - }, - - /* ── Attention toast management (VAL-ARCH-007) ── */ - addAttentionToast: (message, severity, missionTitle, missionId) => { - const id = globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `${Date.now()}-${Math.random()}`; - set((state) => ({ - attentionToasts: [ - { id, missionTitle, message, severity, missionId }, - ...state.attentionToasts, - ].slice(0, 3), - })); - const timer = window.setTimeout(() => get().dismissAttentionToast(id), 12_000); - toastTimers.set(id, timer); - }, - - dismissAttentionToast: (id) => { - set((state) => ({ - attentionToasts: state.attentionToasts.filter((t) => t.id !== id), - })); - const timer = toastTimers.get(id); - if (timer != null) window.clearTimeout(timer); - toastTimers.delete(id); - }, - - cleanupToastTimers: () => { - for (const timer of toastTimers.values()) window.clearTimeout(timer); - toastTimers.clear(); - }, - - /* ── Selection reset ── */ - clearSelection: () => set(buildClearedSelectionState()), - }); -}; - -export type MissionsStoreApi = StoreApi<MissionsStore>; - -const rootMissionsStore = createStore<MissionsStore>()(createMissionsState); -const MissionsStoreContext = createContext<MissionsStoreApi | null>(null); - -export function createMissionsStore(): MissionsStoreApi { - return createStore<MissionsStore>()(createMissionsState); -} - -export function MissionsStoreProvider({ - store, - children, -}: { - store: MissionsStoreApi; - children: ReactNode; -}) { - return React.createElement(MissionsStoreContext.Provider, { value: store }, children); -} - -export function useMissionsStoreApi(): MissionsStoreApi { - return useContext(MissionsStoreContext) ?? rootMissionsStore; -} - -type MissionsStoreHook = { - <T>(selector: (state: MissionsStore) => T): T; - getState: MissionsStoreApi["getState"]; - setState: MissionsStoreApi["setState"]; - subscribe: MissionsStoreApi["subscribe"]; -}; - -export const useMissionsStore = ((selector: (state: MissionsStore) => unknown) => { - const store = useMissionsStoreApi(); - return useStore(store, selector); -}) as MissionsStoreHook; - -useMissionsStore.getState = rootMissionsStore.getState; -useMissionsStore.setState = rootMissionsStore.setState; -useMissionsStore.subscribe = rootMissionsStore.subscribe; - -/* ════════════════════ FINE-GRAINED SELECTORS ════════════════════ */ - -export const selectMissions = (s: MissionsStore) => s.missions; -export const selectSelectedMissionId = (s: MissionsStore) => s.selectedMissionId; -export const selectSelectedMission = (s: MissionsStore) => s.selectedMission; -export const selectRunGraph = (s: MissionsStore) => s.runGraph; -export const selectDashboard = (s: MissionsStore) => s.dashboard; -export const selectLoading = (s: MissionsStore) => s.loading; -export const selectError = (s: MissionsStore) => s.error; -export const selectRefreshing = (s: MissionsStore) => s.refreshing; -export const selectActiveTab = (s: MissionsStore) => s.activeTab; -export const selectSearchFilter = (s: MissionsStore) => s.searchFilter; -export const selectMissionListView = (s: MissionsStore) => s.missionListView; -export const selectRunBusy = (s: MissionsStore) => s.runBusy; -export const selectActiveInterventionId = (s: MissionsStore) => s.activeInterventionId; -export const selectAttentionToasts = (s: MissionsStore) => s.attentionToasts; -export const selectCheckpointStatus = (s: MissionsStore) => s.checkpointStatus; -export const selectMissionSettingsOpen = (s: MissionsStore) => s.missionSettingsOpen; -export const selectMissionSettingsDraft = (s: MissionsStore) => s.missionSettingsDraft; -export const selectPlanSubview = (s: MissionsStore) => s.planSubview; -export const selectMissionContextMenu = (s: MissionsStore) => s.missionContextMenu; -export const selectSelectedStepId = (s: MissionsStore) => s.selectedStepId; -export const selectActivityPanelMode = (s: MissionsStore) => s.activityPanelMode; -export const selectOrchestratorArtifacts = (s: MissionsStore) => s.orchestratorArtifacts; -export const selectWorkerCheckpoints = (s: MissionsStore) => s.workerCheckpoints; -export const selectModelCapabilities = (s: MissionsStore) => s.modelCapabilities; -export const selectSteerBusy = (s: MissionsStore) => s.steerBusy; -export const selectCleanupBusy = (s: MissionsStore) => s.cleanupBusy; -export const selectManageMission = (s: MissionsStore) => s.manageMission; -export const selectManageMissionOpen = (s: MissionsStore) => s.manageMissionOpen; -export const selectManageMissionBusy = (s: MissionsStore) => s.manageMissionBusy; -export const selectManageMissionError = (s: MissionsStore) => s.manageMissionError; -export const selectManageMissionCleanupLanes = (s: MissionsStore) => s.manageMissionCleanupLanes; -export const selectChatJumpTarget = (s: MissionsStore) => s.chatJumpTarget; -export const selectLogsFocusInterventionId = (s: MissionsStore) => s.logsFocusInterventionId; diff --git a/apps/desktop/src/renderer/components/onboarding/WelcomeWizard.tsx b/apps/desktop/src/renderer/components/onboarding/WelcomeWizard.tsx index aa3a4c1d7..64c736aba 100644 --- a/apps/desktop/src/renderer/components/onboarding/WelcomeWizard.tsx +++ b/apps/desktop/src/renderer/components/onboarding/WelcomeWizard.tsx @@ -5,7 +5,6 @@ import { openExternalUrl } from "../../lib/openExternal"; import { WelcomeIllustration, LanesIllustration, - MissionsIllustration, HelpIllustration, } from "./illustrations/WelcomeIllustrations"; import { docs } from "../../onboarding/docsLinks"; @@ -38,13 +37,6 @@ export const WELCOME_SCREENS: Screen[] = [ learnMoreHref: docs.lanesOverview, Illustration: LanesIllustration, }, - { - eyebrow: "Hand work to AI", - title: "Missions & Workers", - body: "Describe a job in plain words and a Worker does it on its own Lane. Your main branch stays untouched.", - learnMoreHref: docs.keyConcepts, - Illustration: MissionsIllustration, - }, { eyebrow: "Always within reach", title: "Help lives in the top right", diff --git a/apps/desktop/src/renderer/components/onboarding/illustrations/WelcomeIllustrations.tsx b/apps/desktop/src/renderer/components/onboarding/illustrations/WelcomeIllustrations.tsx index ab7c9d8b7..16e720866 100644 --- a/apps/desktop/src/renderer/components/onboarding/illustrations/WelcomeIllustrations.tsx +++ b/apps/desktop/src/renderer/components/onboarding/illustrations/WelcomeIllustrations.tsx @@ -124,62 +124,6 @@ export function LanesIllustration() { ); } -export function MissionsIllustration() { - // A chat bubble pointing at a target with a small bot figure. - return ( - <Frame label="A Worker running a Mission on its own Lane"> - <defs> - <linearGradient id="ade-mission-ring" x1="0" x2="1" y1="0" y2="1"> - <stop offset="0%" stopColor={ACCENT} /> - <stop offset="100%" stopColor="#EC4899" /> - </linearGradient> - </defs> - {/* Target on the right */} - <g transform="translate(232, 80)"> - <circle r="36" fill="none" stroke={LINE} strokeWidth="1" /> - <circle r="24" fill="none" stroke={LINE} strokeWidth="1" /> - <circle r="12" fill="none" stroke="url(#ade-mission-ring)" strokeWidth="2" /> - <circle r="4" fill={ACCENT}> - <animate attributeName="r" values="4;6;4" dur="2.4s" repeatCount="indefinite" /> - </circle> - </g> - {/* Worker bubble on the left */} - <g transform="translate(30, 52)"> - <rect width="120" height="56" rx="12" fill={SURFACE} stroke={LINE} /> - {/* Bot head icon */} - <g transform="translate(12, 14)"> - <rect x="0" y="3" width="22" height="20" rx="5" fill={ACCENT} opacity="0.9" /> - <circle cx="7" cy="13" r="2" fill={INK} /> - <circle cx="15" cy="13" r="2" fill={INK} /> - <rect x="-2" y="9" width="2" height="6" rx="1" fill={ACCENT} opacity="0.5" /> - <rect x="22" y="9" width="2" height="6" rx="1" fill={ACCENT} opacity="0.5" /> - </g> - {/* Message bars */} - <rect x="44" y="14" width="64" height="5" rx="2.5" fill={INK} opacity="0.8" /> - <rect x="44" y="24" width="52" height="4" rx="2" fill={LINE} /> - <rect x="44" y="33" width="44" height="4" rx="2" fill={LINE} /> - {/* Tail */} - <path d="M120 42 L132 48 L120 54 Z" fill={SURFACE} stroke={LINE} /> - </g> - {/* Travel dots from bubble to target */} - <g> - <circle cx="160" cy="80" r="3" fill={ACCENT}> - <animate attributeName="cx" values="160;220;160" dur="2.6s" repeatCount="indefinite" /> - <animate attributeName="opacity" values="0.9;0.1;0.9" dur="2.6s" repeatCount="indefinite" /> - </circle> - <circle cx="180" cy="80" r="2" fill={ACCENT}> - <animate attributeName="cx" values="180;220;180" dur="2.6s" begin="0.4s" repeatCount="indefinite" /> - <animate attributeName="opacity" values="0.7;0.1;0.7" dur="2.6s" begin="0.4s" repeatCount="indefinite" /> - </circle> - <circle cx="200" cy="80" r="2" fill={ACCENT}> - <animate attributeName="cx" values="200;220;200" dur="2.6s" begin="0.8s" repeatCount="indefinite" /> - <animate attributeName="opacity" values="0.5;0.1;0.5" dur="2.6s" begin="0.8s" repeatCount="indefinite" /> - </circle> - </g> - </Frame> - ); -} - export function HelpIllustration() { // Mock top-right with a highlighted ? button return ( diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index 5141b3bad..686f408e9 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -12,7 +12,6 @@ import { WorkflowsTab, type WorkflowCategory } from "./tabs/WorkflowsTab"; import { GitHubRepoSyncBar } from "./shared/GitHubRepoSyncBar"; import { GitHubPrSearchInput } from "./shared/GitHubPrSearchInput"; import { SANS_FONT } from "../lanes/laneDesignTokens"; -import { isMissionLaneHiddenByDefault } from "../lanes/laneUtils"; import { buildPrsRouteSearch, parsePrsRouteState, @@ -141,10 +140,7 @@ function PRsPageInner() { return null; } }); - const visibleLanes = React.useMemo( - () => lanes.filter((lane) => !isMissionLaneHiddenByDefault(lane)), - [lanes], - ); + const visibleLanes = lanes; React.useEffect(() => { if (activeTab !== "normal") { diff --git a/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.test.ts b/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.test.ts index 94b23b3b9..75eaf858b 100644 --- a/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.test.ts +++ b/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.test.ts @@ -61,8 +61,6 @@ function makeLane(overrides: Partial<LaneSummary> = {}): LaneSummary { icon: overrides.icon ?? null, tags: overrides.tags ?? [], folder: overrides.folder ?? null, - missionId: overrides.missionId ?? null, - laneRole: overrides.laneRole ?? null, createdAt: overrides.createdAt ?? "2026-04-01T11:00:00.000Z", archivedAt: overrides.archivedAt ?? null, }; diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx index a738f08f0..549ec0e1e 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx @@ -213,7 +213,7 @@ describe("ReviewPage", () => { title: "Provenance brief", mimeType: "application/json", contentText: "{\"summary\":\"Lane history and prior review signals\"}", - metadata: { provenanceCount: 4, missionCount: 1, workerDigestCount: 3, sessionDeltaCount: 2, priorReviewCount: 1 }, + metadata: { provenanceCount: 4, workerDigestCount: 3, sessionDeltaCount: 2, priorReviewCount: 1 }, createdAt: "2026-04-03T12:01:00.000Z", } as any, { diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx index 1583dc1d8..67d4dc8f8 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -1770,14 +1770,13 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { const artifactType = String(artifact.artifactType); const countValue = artifactType === "provenance_brief" - ? readArtifactMetaCount(artifact, ["provenanceCount", "missionCount", "workerDigestCount", "sessionDeltaCount", "priorReviewCount"]) + ? readArtifactMetaCount(artifact, ["provenanceCount", "workerDigestCount", "sessionDeltaCount", "priorReviewCount"]) : artifactType === "rule_overlays" ? readArtifactMetaCount(artifact, ["ruleCount", "matchedRuleCount", "overlayCount", "pathCount"]) : readArtifactMetaCount(artifact, ["signalCount", "checkCount", "testRunCount", "issueCount"]); const detailChips = artifactType === "provenance_brief" ? [ - readArtifactMetaCount(artifact, ["missionCount", "missionsCount"]) ? `missions ${readArtifactMetaCount(artifact, ["missionCount", "missionsCount"])}` : null, readArtifactMetaCount(artifact, ["workerDigestCount", "workerCount"]) ? `workers ${readArtifactMetaCount(artifact, ["workerDigestCount", "workerCount"])}` : null, readArtifactMetaCount(artifact, ["sessionDeltaCount", "sessionCount"]) ? `sessions ${readArtifactMetaCount(artifact, ["sessionDeltaCount", "sessionCount"])}` : null, ].filter((value): value is string => Boolean(value)) diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 0c65f7b6e..7fcc8245d 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -31,7 +31,7 @@ const FEATURES = [ { icon: ArrowsLeftRight, title: "Issue routing", desc: "Attach Linear issues to lanes, chats, and the work that happened there" }, { icon: Lightning, title: "PR linkage", desc: "Carry Linear refs, ADE links, and issue lists into GitHub PRs" }, { icon: ArrowsClockwise, title: "Linear timeline", desc: "Publish ADE lane and PR cards back onto the Linear issue" }, - { icon: Plugs, title: "CTO workflows", desc: "Dispatch missions directly from Linear and keep status context close" }, + { icon: Plugs, title: "CTO workflows", desc: "Dispatch work directly from Linear and keep status context close" }, ]; export function LinearSection() { diff --git a/apps/desktop/src/renderer/components/shared/MentionInput.tsx b/apps/desktop/src/renderer/components/shared/MentionInput.tsx index a7200ac49..3cb3242ee 100644 --- a/apps/desktop/src/renderer/components/shared/MentionInput.tsx +++ b/apps/desktop/src/renderer/components/shared/MentionInput.tsx @@ -55,7 +55,7 @@ export function MentionInput({ disabled = false, autoFocus = false, footerHint = "Press Enter to send. Shift+Enter keeps a newline.", - surfaceMode = "mission-thread", + surfaceMode = "standard", }: MentionInputProps) { const [showDropdown, setShowDropdown] = useState(false); const [dropdownFilter, setDropdownFilter] = useState(""); diff --git a/apps/desktop/src/renderer/components/missions/ModelSelector.tsx b/apps/desktop/src/renderer/components/shared/ModelSelector.tsx similarity index 86% rename from apps/desktop/src/renderer/components/missions/ModelSelector.tsx rename to apps/desktop/src/renderer/components/shared/ModelSelector.tsx index 289d23937..84febd481 100644 --- a/apps/desktop/src/renderer/components/missions/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelSelector.tsx @@ -1,18 +1,15 @@ -import React, { useCallback, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import type { ModelConfig, ModelProvider, ThinkingLevel } from "../../../shared/types"; import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; -import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; +import { ModelPicker } from "./ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "./ModelPicker/ReasoningEffortPicker"; type ModelSelectorProps = { value: ModelConfig; onChange: (config: ModelConfig) => void; compact?: boolean; - showRecommendedBadge?: boolean; - /** When provided, only models whose registry id is in this set are shown. */ availableModelIds?: string[]; onOpenAiSettings?: () => void; - /** Stable id used by the picker to remember per-surface defaults. */ surfaceKey?: string; fastModeActive?: boolean; onFastModeToggle?: (next: boolean) => void; @@ -41,10 +38,9 @@ export function ModelSelector({ value, onChange, compact, - showRecommendedBadge: _showRecommendedBadge, availableModelIds, onOpenAiSettings, - surfaceKey = "missions/phase-or-action", + surfaceKey = "automations/model", fastModeActive, onFastModeToggle, fastModeSupported, diff --git a/apps/desktop/src/renderer/components/shared/permissionOptions.ts b/apps/desktop/src/renderer/components/shared/permissionOptions.ts index 8a1087c4d..e1afe9df0 100644 --- a/apps/desktop/src/renderer/components/shared/permissionOptions.ts +++ b/apps/desktop/src/renderer/components/shared/permissionOptions.ts @@ -1,6 +1,6 @@ // --------------------------------------------------------------------------- -// Shared permission option definitions — used by both chat composer and -// mission dialog to keep per-provider permission UIs in sync. +// Shared permission option definitions used by chat, PR resolver, and +// automation UIs to keep per-provider permission choices in sync. // --------------------------------------------------------------------------- import type { AgentChatPermissionMode, ChatSurfaceProfile } from "../../../shared/types"; @@ -400,7 +400,7 @@ export function safetyColors(safety: SafetyLevel) { /** * Map a ProviderFamily string to the permission-family key used by - * MissionProviderPermissions ("claude" | "codex" | "cursor" | "droid" | "opencode"). + * provider permission config ("claude" | "codex" | "cursor" | "droid" | "opencode"). * * Only CLI-wrapped anthropic → "claude" and CLI-wrapped openai → "codex". * All API / local models (even anthropic-api or openai-api) use "opencode". diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index b6851f765..701f5813b 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -40,8 +40,6 @@ const workMocks = vi.hoisted(() => { icon: null, tags: [], folder: null, - missionId: null, - laneRole: null, status: laneStatus, createdAt: "2026-05-14T18:00:00.000Z", archivedAt: null, diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx index caa3c3b9c..da5795461 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx @@ -147,7 +147,6 @@ const lane: LaneSummary = { color: null, icon: null, tags: [], - missionId: null, }; const laneTwo: LaneSummary = { diff --git a/apps/desktop/src/renderer/components/ui/TabBackground.tsx b/apps/desktop/src/renderer/components/ui/TabBackground.tsx index d6a478c3d..8bde6d7fb 100644 --- a/apps/desktop/src/renderer/components/ui/TabBackground.tsx +++ b/apps/desktop/src/renderer/components/ui/TabBackground.tsx @@ -3,7 +3,7 @@ import { useLocation } from "react-router-dom"; import { cn } from "./cn"; function primaryTabPath(pathname: string): string { - const roots = ["/project", "/lanes", "/files", "/work", "/graph", "/prs", "/review", "/history", "/automations", "/missions", "/cto", "/vm", "/settings"]; + const roots = ["/project", "/lanes", "/files", "/work", "/graph", "/prs", "/review", "/history", "/automations", "/cto", "/vm", "/settings"]; return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } @@ -17,8 +17,7 @@ const routeToTabBg: Record<string, string> = { "/review": "ade-tab-bg-review", "/history": "ade-tab-bg-history", "/automations": "ade-tab-bg-automations", - "/missions": "ade-tab-bg-missions", - "/cto": "ade-tab-bg-missions", + "/cto": "ade-tab-bg-cto", "/vm": "ade-tab-bg-terminals", "/settings": "ade-tab-bg-settings", }; @@ -33,8 +32,7 @@ const routeToTint: Record<string, string> = { "/review": "tab-tint-review", "/history": "tab-tint-history", "/automations": "tab-tint-automations", - "/missions": "tab-tint-missions", - "/cto": "tab-tint-missions", + "/cto": "tab-tint-cto", "/vm": "tab-tint-work", "/settings": "tab-tint-settings", }; diff --git a/apps/desktop/src/renderer/components/vm/MacVmPage.test.tsx b/apps/desktop/src/renderer/components/vm/MacVmPage.test.tsx index 6d88007ec..bee4fdb7a 100644 --- a/apps/desktop/src/renderer/components/vm/MacVmPage.test.tsx +++ b/apps/desktop/src/renderer/components/vm/MacVmPage.test.tsx @@ -47,7 +47,6 @@ const baseLane: LaneSummary = { color: null, icon: null, tags: [], - missionId: null, } as LaneSummary; const emptyStatus: MacosVmStatus = { diff --git a/apps/desktop/src/renderer/components/vm/MacVmPage.tsx b/apps/desktop/src/renderer/components/vm/MacVmPage.tsx index c28cbd67f..dd56f8f91 100644 --- a/apps/desktop/src/renderer/components/vm/MacVmPage.tsx +++ b/apps/desktop/src/renderer/components/vm/MacVmPage.tsx @@ -226,7 +226,7 @@ type AppPackagingState = "checking" | "packaged" | "dev"; /** * Gate the Mac VM tab behind a "Coming soon" splash in packaged builds — - * mirrors the MissionsProductionGate pattern. The provisioning + first-boot + * mirrors the production-gate pattern. The provisioning + first-boot * flow still lives in {@link MacVmWorkspace} and runs unchanged in dev. * * The `getInfo()` call is guarded so existing renderer tests that stub diff --git a/apps/desktop/src/renderer/hooks/useThreadEventRefresh.ts b/apps/desktop/src/renderer/hooks/useThreadEventRefresh.ts deleted file mode 100644 index c767a6f8f..000000000 --- a/apps/desktop/src/renderer/hooks/useThreadEventRefresh.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { OrchestratorThreadEventType } from "../../shared/types"; - -const DEFAULT_EVENT_TYPES: OrchestratorThreadEventType[] = [ - "message_appended", - "message_updated", - "worker_replay", -]; - -/** - * Subscribe to orchestrator thread events, debounce-calling `onRefresh` when - * matching events arrive. Cleans up subscription and pending timers on unmount. - */ -export function useThreadEventRefresh(opts: { - missionId: string | null; - threadId?: string | null; - onRefresh: () => void; - debounceMs?: number; - eventTypes?: OrchestratorThreadEventType[]; -}): void { - const { missionId, threadId, onRefresh, debounceMs = 150, eventTypes = DEFAULT_EVENT_TYPES } = opts; - const timerRef = useRef<number | null>(null); - - useEffect(() => { - if (!missionId) return; - - const unsub = window.ade.orchestrator.onThreadEvent((event) => { - if (event.missionId !== missionId) return; - if (!eventTypes.includes(event.type)) return; - if (threadId !== undefined && threadId !== null && event.threadId && event.threadId !== threadId) return; - - if (timerRef.current !== null) { - window.clearTimeout(timerRef.current); - } - timerRef.current = window.setTimeout(() => { - timerRef.current = null; - onRefresh(); - }, debounceMs); - }); - - return () => { - unsub(); - if (timerRef.current !== null) { - window.clearTimeout(timerRef.current); - } - }; - }, [missionId, threadId, onRefresh, debounceMs, eventTypes]); -} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 653d7e349..50d11f6b7 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -322,7 +322,7 @@ .tab-tint-prs { --tab-tint-rgb: 167, 139, 250; } .tab-tint-history { --tab-tint-rgb: 217, 119, 6; } .tab-tint-automations { --tab-tint-rgb: 249, 115, 22; } -.tab-tint-missions { --tab-tint-rgb: 59, 130, 246; } +.tab-tint-cto { --tab-tint-rgb: 59, 130, 246; } .tab-tint-settings { --tab-tint-rgb: 113, 113, 122; } .tab-tint-review { --tab-tint-rgb: 56, 189, 248; } @@ -2255,15 +2255,15 @@ button:active, [role="button"]:active { opacity: 0.35; } -/* 11. Missions — Topographic Contour Map (blue #3b82f6) */ -.ade-tab-bg-missions { +/* 11. CTO — Topographic Contour Map (blue #3b82f6) */ +.ade-tab-bg-cto { background: repeating-radial-gradient(circle at 50% 50%, transparent, transparent 40px, rgba(59, 130, 246, 0.025) 41px, transparent 43px), repeating-radial-gradient(circle at 25% 65%, transparent, transparent 30px, rgba(59, 130, 246, 0.02) 31px, transparent 33px), repeating-radial-gradient(circle at 75% 35%, transparent, transparent 35px, rgba(59, 130, 246, 0.018) 36px, transparent 38px); } -.ade-tab-bg-missions::before { +.ade-tab-bg-cto::before { content: ""; position: absolute; inset: 0; @@ -2346,7 +2346,7 @@ button:active, [role="button"]:active { } -/* Blue glow pulse for active missions */ +/* Blue glow pulse for active work */ .ade-glow-pulse-blue { animation: ade-glow-pulse-blue 2s ease-in-out infinite; } @@ -3194,7 +3194,7 @@ button:active, [role="button"]:active { animation: none; } -/* Tab icon breathing glow for active missions */ +/* Tab icon breathing glow for active work */ .ade-tab-icon-active { position: relative; } diff --git a/apps/desktop/src/renderer/lib/computerUse.test.ts b/apps/desktop/src/renderer/lib/computerUse.test.ts index a94bc613f..5163df607 100644 --- a/apps/desktop/src/renderer/lib/computerUse.test.ts +++ b/apps/desktop/src/renderer/lib/computerUse.test.ts @@ -8,7 +8,7 @@ import { function createSnapshot(overrides: Partial<ComputerUseOwnerSnapshot> = {}): ComputerUseOwnerSnapshot { return { - owner: { kind: "mission", id: "mission-1" }, + owner: { kind: "lane", id: "lane-1" }, backendStatus: { backends: [], localFallback: { @@ -27,14 +27,12 @@ function createSnapshot(overrides: Partial<ComputerUseOwnerSnapshot> = {}): Comp } describe("computerUse renderer helpers", () => { - it("builds route presets for chat, mission, and lane owners", () => { + it("builds route presets for chat and lane owners", () => { expect(buildComputerUseRoutePresets({ chatSessionId: "chat-1", - missionId: "mission-1", laneId: "lane-1", })).toEqual([ { label: "Keep in chat", owner: { kind: "chat_session", id: "chat-1" } }, - { label: "Attach to mission", owner: { kind: "mission", id: "mission-1" } }, { label: "Attach to lane", owner: { kind: "lane", id: "lane-1" } }, ]); }); @@ -44,8 +42,8 @@ describe("computerUse renderer helpers", () => { { id: "link-1", artifactId: "artifact-1", - ownerKind: "mission", - ownerId: "mission-1", + ownerKind: "chat_session", + ownerId: "chat-1", relation: "attached_to", metadata: null, createdAt: "2026-03-12T14:00:00.000Z", @@ -59,7 +57,7 @@ describe("computerUse renderer helpers", () => { metadata: null, createdAt: "2026-03-12T14:01:00.000Z", }, - ])).toBe("mission:mission-1 • lane:lane-1"); + ])).toBe("chat session:chat-1 • lane:lane-1"); expect(summarizeComputerUseProof(createSnapshot())).toBe("No proof captured yet."); expect(summarizeComputerUseProof(createSnapshot({ diff --git a/apps/desktop/src/renderer/lib/computerUse.ts b/apps/desktop/src/renderer/lib/computerUse.ts index dc50e67e2..831fbdfd0 100644 --- a/apps/desktop/src/renderer/lib/computerUse.ts +++ b/apps/desktop/src/renderer/lib/computerUse.ts @@ -22,7 +22,6 @@ export function describeComputerUseLinks(links: ComputerUseArtifactLink[]): stri export function buildComputerUseRoutePresets(args: { laneId?: string | null; - missionId?: string | null; chatSessionId?: string | null; }) { const presets: Array<{ label: string; owner: ComputerUseArtifactOwner }> = []; @@ -32,12 +31,6 @@ export function buildComputerUseRoutePresets(args: { owner: { kind: "chat_session", id: args.chatSessionId }, }); } - if (args.missionId) { - presets.push({ - label: "Attach to mission", - owner: { kind: "mission", id: args.missionId }, - }); - } if (args.laneId) { presets.push({ label: "Attach to lane", diff --git a/apps/desktop/src/renderer/onboarding/docsLinks.ts b/apps/desktop/src/renderer/onboarding/docsLinks.ts index 1d03402d2..a068b90da 100644 --- a/apps/desktop/src/renderer/onboarding/docsLinks.ts +++ b/apps/desktop/src/renderer/onboarding/docsLinks.ts @@ -32,7 +32,6 @@ export const docs = { // Higher-level product areas projectHome: `${DOCS_BASE}/tools/project-home`, - missionsOverview: `${DOCS_BASE}/missions/overview`, ctoOverview: `${DOCS_BASE}/cto/overview`, automationsOverview: `${DOCS_BASE}/automations/overview`, workspaceGraph: `${DOCS_BASE}/tools/workspace-graph`, diff --git a/apps/desktop/src/renderer/onboarding/glossary.ts b/apps/desktop/src/renderer/onboarding/glossary.ts index 7b8112021..77604abde 100644 --- a/apps/desktop/src/renderer/onboarding/glossary.ts +++ b/apps/desktop/src/renderer/onboarding/glossary.ts @@ -49,14 +49,6 @@ export const GLOSSARY: Term[] = [ "An Attached Lane is a worktree that lives outside `.ade/worktrees` but that ADE now tracks. You can leave it where it is or move it into `.ade/worktrees` to have ADE fully manage it.", docUrl: docs.lanesCreating, }, - { - id: "mission", - term: "Mission", - shortDefinition: "A job you hand to ADE to complete.", - longDefinition: - "A Mission is a piece of work described in plain words. ADE breaks it into steps and runs them, usually in its own Lane with one or more Workers doing the job.", - docUrl: docs.keyConcepts, - }, { id: "worker", term: "Worker", diff --git a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts index 5c04df96d..77e23d29a 100644 --- a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts @@ -614,6 +614,39 @@ const act9Intro: TourStep = { docUrl: docs.automationsOverview, }; +const act9Triggers: TourStep = { + id: "act9.triggers", + target: '[data-tour="automations.createTrigger"]', + title: "Triggers", + body: "Pick what starts the automation — a webhook, a schedule, a git event, or a file watch. One automation can wire up several.", + placement: "right", + requires: PROJECT_OPEN_REQUIRES, + waitForSelector: '[data-tour="automations.createTrigger"]', + docUrl: docs.automationsOverview, +}; + +const act9Actions: TourStep = { + id: "act9.actions", + target: '[data-tour="automations.createTrigger"]', + title: "Actions", + body: "What happens when a trigger fires — run a command, open an agent session, ping a worker. Chain them in order.", + placement: "right", + requires: PROJECT_OPEN_REQUIRES, + waitForSelector: '[data-tour="automations.createTrigger"]', + docUrl: docs.automationsOverview, +}; + +const act9Guardrails: TourStep = { + id: "act9.guardrails", + target: '[data-tour="automations.createTrigger"]', + title: "Guardrails", + body: "Rate limits, concurrency caps, quiet hours. Guardrails stop an automation from going rogue — set them before you save.", + placement: "right", + requires: PROJECT_OPEN_REQUIRES, + waitForSelector: '[data-tour="automations.createTrigger"]', + docUrl: docs.automationsOverview, +}; + // --- Bonus Act 10: CTO ------------------------------------------------------ const act10Intro: TourStep = { id: "act10.intro", @@ -626,6 +659,45 @@ const act10Intro: TourStep = { docUrl: docs.ctoOverview, }; +const act10Sidebar: TourStep = { + id: "act10.sidebar", + target: '[data-tour="cto.sidebar"]', + title: "Your agents", + body: "The sidebar lists every agent the CTO manages. Identities persist — each one remembers who they are between sessions.", + placement: "right", + requires: PROJECT_OPEN_REQUIRES, + waitForSelector: '[data-tour="cto.sidebar"]', + docUrl: docs.ctoOverview, +}; + +const act10Team: TourStep = { + id: "act10.team", + target: '[data-tour="cto.teamPanel"]', + title: "Team panel", + body: "Inspect, edit, or archive agents. Budget caps and heartbeat intervals live here too — set them low while you're learning.", + placement: "left", + requires: PROJECT_OPEN_REQUIRES, + waitForSelector: '[data-tour="cto.teamPanel"]', + docUrl: docs.ctoOverview, +}; + +const act10Linear: TourStep = { + id: "act10.linear", + target: '[data-tour="cto.linearPanel"]', + title: "Linear sync", + body: "Hook the CTO up to Linear and it auto-dispatches work from tickets, posting results back to the issue. Skip if you don't use Linear.", + placement: "left", + requires: PROJECT_OPEN_REQUIRES, + beforeEnter: async () => [{ + type: "ipc", + call: async () => { + window.dispatchEvent(new CustomEvent("ade:tour-cto-tab", { detail: "workflows" })); + }, + }], + waitForSelector: '[data-tour="cto.linearPanel"]', + docUrl: docs.ctoOverview, +}; + // --- Bonus Act 11: Settings -------------------------------------------------- const act11Intro: TourStep = { id: "act11.intro", diff --git a/apps/desktop/src/renderer/onboarding/tours/graphHighlightsTour.ts b/apps/desktop/src/renderer/onboarding/tours/graphHighlightsTour.ts index 694f97792..1c523b079 100644 --- a/apps/desktop/src/renderer/onboarding/tours/graphHighlightsTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/graphHighlightsTour.ts @@ -11,7 +11,7 @@ export const graphHighlightsTour: Tour = { id: "h.graph.what", target: "", title: "Workspace graph", - body: "Every lane, mission, and PR as a node. Edges show parent and child branches and stack relationships.", + body: "Every lane and PR as a node. Edges show parent and child branches and stack relationships.", docUrl: docs.workspaceGraph, }, { diff --git a/apps/desktop/src/renderer/onboarding/tours/historyHighlightsTour.ts b/apps/desktop/src/renderer/onboarding/tours/historyHighlightsTour.ts index d131d2eaf..a0bb58bca 100644 --- a/apps/desktop/src/renderer/onboarding/tours/historyHighlightsTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/historyHighlightsTour.ts @@ -11,7 +11,7 @@ export const historyHighlightsTour: Tour = { id: "h.history.what", target: "", title: "History", - body: "A timeline of everything that happened: lane created, commit, push, PR opened, mission step — every event, forever.", + body: "A timeline of everything that happened: lane created, commit, push, PR opened — every event, forever.", docUrl: docs.home, }, { diff --git a/apps/desktop/src/renderer/onboarding/tours/index.ts b/apps/desktop/src/renderer/onboarding/tours/index.ts index f4a426782..0f7ed6427 100644 --- a/apps/desktop/src/renderer/onboarding/tours/index.ts +++ b/apps/desktop/src/renderer/onboarding/tours/index.ts @@ -12,7 +12,6 @@ import "./graphTour"; import "./prsTour"; import "./historyTour"; import "./automationsTour"; -import "./missionsTour"; import "./ctoTour"; import "./settingsTour"; import "./lanesHighlightsTour"; diff --git a/apps/desktop/src/renderer/onboarding/tours/missionsTour.ts b/apps/desktop/src/renderer/onboarding/tours/missionsTour.ts deleted file mode 100644 index d0dbfb916..000000000 --- a/apps/desktop/src/renderer/onboarding/tours/missionsTour.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerTour, type Tour } from "../registry"; - -export const missionsTour: Tour = { - id: "missions", - title: "Missions walkthrough", - route: "/missions", - steps: [], -}; - -registerTour(missionsTour); - -export default missionsTour; diff --git a/apps/desktop/src/renderer/perf/scenarios/index.ts b/apps/desktop/src/renderer/perf/scenarios/index.ts index 4bca028af..72b9e2df5 100644 --- a/apps/desktop/src/renderer/perf/scenarios/index.ts +++ b/apps/desktop/src/renderer/perf/scenarios/index.ts @@ -94,9 +94,6 @@ async function loadScenarioModule(scenarioId: string): Promise<void> { case "lanes": await import("./lanes"); return; - case "missions": - await import("./missions"); - return; case "boot": await import("./boot"); return; diff --git a/apps/desktop/src/renderer/perf/scenarios/missions.ts b/apps/desktop/src/renderer/perf/scenarios/missions.ts deleted file mode 100644 index 1ec1ac073..000000000 --- a/apps/desktop/src/renderer/perf/scenarios/missions.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { registerScenario, type ScenarioContext } from "./index"; - -const MISSION_TABS = ["overview", "chat", "plan", "history", "artifacts"] as const; - -function routeTo(path: string): void { - const target = path.startsWith("/") ? path : `/${path}`; - if (window.location.protocol === "http:" || window.location.protocol === "https:") { - window.history.pushState(null, "", target); - window.dispatchEvent(new PopStateEvent("popstate")); - return; - } - window.location.hash = target.startsWith("#") ? target : `#${target}`; -} - -async function navigateToMissions(ctx: ScenarioContext, missionId?: string): Promise<void> { - const suffix = missionId ? `?missionId=${encodeURIComponent(missionId)}` : ""; - ctx.mark("nav.missions.start"); - routeTo(`/missions${suffix}`); - const present = await ctx.waitFor( - () => document.querySelector("[data-route='missions']") !== null, - 8_000, - ); - ctx.mark("nav.missions.done"); - ctx.measure("nav.missions", "nav.missions.start", "nav.missions.done"); - ctx.assert(present, "missions route did not render main content"); -} - -function chooseScenarioMission( - missions: Awaited<ReturnType<typeof window.ade.missions.list>>, -): string | null { - const completedWithPlan = missions.find( - (mission) => mission.status === "completed" && mission.totalSteps > 0, - ); - const completed = missions.find((mission) => mission.status === "completed"); - const active = missions.find( - (mission) => mission.status === "in_progress" || mission.status === "intervention_required", - ); - return (completedWithPlan ?? completed ?? active ?? missions[0])?.id ?? null; -} - -async function openTab(ctx: ScenarioContext, tab: typeof MISSION_TABS[number]): Promise<void> { - const button = document.getElementById(`mission-tab-${tab}`) as HTMLButtonElement | null; - if (!button) { - ctx.assert(false, `mission tab button missing: ${tab}`); - return; - } - ctx.mark(`missions.tab.${tab}.start`); - button.click(); - const rendered = await ctx.waitFor( - () => document.getElementById(`mission-panel-${tab}`) !== null, - 4_000, - ); - ctx.mark(`missions.tab.${tab}.done`); - ctx.measure(`missions.tab.${tab}`, `missions.tab.${tab}.start`, `missions.tab.${tab}.done`); - ctx.assert(rendered, `mission tab did not render: ${tab}`); - await ctx.idle(250); -} - -registerScenario({ - id: "missions.cold-dashboard", - description: "Cold open of /missions — measures route mount plus summary/dashboard IPC.", - run: async (ctx) => { - await navigateToMissions(ctx); - - ctx.mark("missions.list.start"); - const missions = await window.ade.missions.list({ limit: 300 }); - ctx.mark("missions.list.done"); - ctx.measure("missions.list", "missions.list.start", "missions.list.done"); - ctx.assert(Array.isArray(missions), "missions.list did not return an array"); - - ctx.mark("missions.dashboard.start"); - const dashboard = await window.ade.missions.getDashboard(); - ctx.mark("missions.dashboard.done"); - ctx.measure("missions.dashboard", "missions.dashboard.start", "missions.dashboard.done"); - ctx.assert(dashboard !== null, "missions dashboard did not return"); - - await window.ade.perf.recordEvent({ - kind: "note", - ts: Date.now(), - note: "missions.dashboardSummary", - missionCount: missions.length, - activeCount: dashboard.active.length, - recentCount: dashboard.recent.length, - }); - await ctx.idle(5_000); - }, -}); - -registerScenario({ - id: "missions.select-and-tabs", - description: "Select a real mission and switch overview/chat/plan/timeline/artifacts tabs.", - run: async (ctx) => { - await navigateToMissions(ctx); - - ctx.mark("missions.select.list.start"); - const missions = await window.ade.missions.list({ limit: 300 }); - ctx.mark("missions.select.list.done"); - ctx.measure("missions.select.list", "missions.select.list.start", "missions.select.list.done"); - - const missionId = chooseScenarioMission(missions); - if (!missionId) { - await window.ade.perf.recordEvent({ - kind: "note", - ts: Date.now(), - note: "missions.select skipped: no missions available", - }); - return; - } - - ctx.mark("missions.fullView.ipc.start"); - const fullView = await window.ade.missions.getFullMissionView({ missionId }); - ctx.mark("missions.fullView.ipc.done"); - ctx.measure("missions.fullView.ipc", "missions.fullView.ipc.start", "missions.fullView.ipc.done"); - ctx.assert(fullView.mission?.id === missionId, "getFullMissionView returned the wrong mission"); - - await window.ade.perf.recordEvent({ - kind: "note", - ts: Date.now(), - note: "missions.selectedSummary", - missionId, - status: fullView.mission?.status ?? null, - stepCount: fullView.runGraph?.steps.length ?? 0, - timelineCount: fullView.runGraph?.timeline.length ?? 0, - artifactCount: fullView.artifacts.length, - checkpointCount: fullView.checkpoints.length, - }); - - await navigateToMissions(ctx, missionId); - const tabsReady = await ctx.waitFor( - () => document.getElementById("mission-tab-plan") !== null, - 8_000, - ); - ctx.assert(tabsReady, "mission tabs did not render after selecting a mission"); - if (!tabsReady) return; - - for (const tab of MISSION_TABS) { - await openTab(ctx, tab); - } - - await ctx.idle(8_000); - }, -}); - -registerScenario({ - id: "missions.idle-at-rest", - description: "Sit on /missions for 30s — measures at-rest polling and memory.", - run: async (ctx) => { - await navigateToMissions(ctx); - await ctx.idle(30_000); - ctx.assert(document.querySelector("[data-route='missions']") !== null, "missions route vanished during idle"); - }, -}); diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index bbe4e329b..2ca1f8b24 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -9,7 +9,7 @@ describe("ADE CLI guidance", () => { expect(guidance).toContain("project, user, runtime, and bundled ADE skill roots"); expect(guidance).toContain("ADE-hosted Work chats"); expect(guidance).toContain("ADE Code/TUI sessions"); - expect(guidance).toContain("CTO and mission worker prompts"); + expect(guidance).toContain("CTO prompts"); expect(guidance).toContain("mobile-started work"); expect(guidance).toContain("ADE_AGENT_SKILLS_DIRS"); expect(guidance).toContain("<skill-name>/SKILL.md"); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 1c390c9e7..75af3afa7 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -7,7 +7,6 @@ export const adeBundledAgentSkills = [ "ade-browser", "ade-pr-workflows", "ade-lanes-git", - "ade-cto-missions", "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", @@ -16,13 +15,13 @@ export const adeBundledAgentSkills = [ export function buildAdeCliAgentGuidance(skillRoots: readonly string[] = getAdeAgentSkillRootsForPrompt()): string { return [ "## ADE CLI", - "ADE is a local-first desktop development environment for lanes, chats, terminal sessions, missions, PR workflows, proof/artifacts, App Control, iOS Simulator/Preview Lab state, the VM tab, config, and managed processes.", - "`ade` is the default control plane for ADE-managed sessions. Use normal shell commands for immediate repo inspection/edit/test work; use ADE CLI when you need ADE state, drawer/session state, proof registration, missions, PR metadata, or managed app/simulator/browser/VM control.", + "ADE is a local-first desktop development environment for lanes, chats, terminal sessions, PR workflows, proof/artifacts, App Control, iOS Simulator/Preview Lab state, the VM tab, config, and managed processes.", + "`ade` is the default control plane for ADE-managed sessions. Use normal shell commands for immediate repo inspection/edit/test work; use ADE CLI when you need ADE state, drawer/session state, proof registration, PR metadata, or managed app/simulator/browser/VM control.", "", "### Skills", "- ADE exposes Agent Skills from project, user, runtime, and bundled ADE skill roots. Use the relevant skill instead of relying on long prompt guidance.", `- Bundled ADE skills include: ${adeBundledAgentSkills.map((name) => `\`${name}\``).join(", ")}.`, - "- ADE injects this guidance into ADE-hosted Work chats, Work tab CLI launches, ADE Code/TUI sessions, CTO and mission worker prompts, and mobile-started work that executes through ADE's desktop or project runtime.", + "- ADE injects this guidance into ADE-hosted Work chats, Work tab CLI launches, ADE Code/TUI sessions, CTO prompts, and mobile-started work that executes through ADE's desktop or project runtime.", "- Skills use the Agent Skills package shape: `<skill-name>/SKILL.md` plus optional `references/`, `scripts/`, and `assets/` files. When a skill applies, read its `SKILL.md` before acting, then load referenced files only when needed.", "- If skills are not auto-listed by your runtime, look for them in project/user `.agents/skills`, `.ade/skills`, `.claude/skills`, or ADE's bundled `agent-skills` resources, then read that skill's `SKILL.md` on demand.", `- ${formatAdeAgentSkillRootsForPrompt(skillRoots)}`, @@ -33,7 +32,7 @@ export function buildAdeCliAgentGuidance(skillRoots: readonly string[] = getAdeA "- If `command -v ade` fails, try `${ADE_CLI_PATH:-}` when set, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout fall back to `node apps/ade-cli/dist/cli.cjs ...` after confirming it exists. The only normal reason to skip ADE CLI for an ADE action is that it is truly unreachable.", "- Use typed commands with `--text` for readable output. Common starts: `ade lanes list --text`, `ade chat list --text`, `ade proof status --text`, and `ade actions list --text`.", "- Use `--socket` when the ADE desktop drawer and the CLI must share live state such as App Control, iOS Simulator, Preview Lab, browser tabs, terminal logs, selection/context capture, proof drawer updates, or macOS VM state.", - "- When the user asks you to capture, send, attach, or provide proof, use the relevant capture tool first, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat, mission, or lane.", + "- When the user asks you to capture, send, attach, or provide proof, use the relevant capture tool first, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat or lane.", "- When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task.", ].join("\n"); } diff --git a/apps/desktop/src/shared/adeLayout.ts b/apps/desktop/src/shared/adeLayout.ts index 91f924e7e..667d0b1a6 100644 --- a/apps/desktop/src/shared/adeLayout.ts +++ b/apps/desktop/src/shared/adeLayout.ts @@ -45,7 +45,6 @@ export type AdeLayoutPaths = { agentContextDir: string; agentConfigsDir: string; workerPromptsDir: string; - missionStateDir: string; githubSecretsDir: string; }; @@ -124,7 +123,6 @@ export function resolveAdeLayout(projectRoot: string): AdeLayoutPaths { agentContextDir: path.join(orchestratorCacheDir, "agent-context"), agentConfigsDir: path.join(orchestratorCacheDir, "agent-configs"), workerPromptsDir: path.join(orchestratorCacheDir, "worker-prompts"), - missionStateDir: path.join(cacheDir, "mission-state"), githubSecretsDir: path.join(secretsDir, "github"), }; _layoutCache.set(projectRoot, result); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 8b6eb568f..d1685c801 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -422,37 +422,9 @@ export const IPC = { reviewQualityReport: "ade.review.qualityReport", reviewEvent: "ade.review.event", adeActionsListRegistry: "ade.actions.listRegistry", - missionsList: "ade.missions.list", - missionsGet: "ade.missions.get", - missionsCreate: "ade.missions.create", - missionsUpdate: "ade.missions.update", - missionsArchive: "ade.missions.archive", - missionsDelete: "ade.missions.delete", - missionsUpdateStep: "ade.missions.updateStep", - missionsAddArtifact: "ade.missions.addArtifact", - missionsAddIntervention: "ade.missions.addIntervention", - missionsResolveIntervention: "ade.missions.resolveIntervention", - missionsListPhaseProfiles: "ade.missions.listPhaseProfiles", - missionsListPhaseItems: "ade.missions.listPhaseItems", - missionsSavePhaseItem: "ade.missions.savePhaseItem", - missionsDeletePhaseItem: "ade.missions.deletePhaseItem", - missionsImportPhaseItems: "ade.missions.importPhaseItems", - missionsExportPhaseItems: "ade.missions.exportPhaseItems", - missionsSavePhaseProfile: "ade.missions.savePhaseProfile", - missionsDeletePhaseProfile: "ade.missions.deletePhaseProfile", - missionsClonePhaseProfile: "ade.missions.clonePhaseProfile", - missionsExportPhaseProfile: "ade.missions.exportPhaseProfile", - missionsImportPhaseProfile: "ade.missions.importPhaseProfile", - missionsGetPhaseConfiguration: "ade.missions.getPhaseConfiguration", - missionsGetDashboard: "ade.missions.getDashboard", - missionsGetFullMissionView: "ade.missions.getFullMissionView", - missionsPreflight: "ade.missions.preflight", - missionsGetRunView: "ade.missions.getRunView", - missionsEvent: "ade.missions.event", orchestratorListRuns: "ade.orchestrator.listRuns", orchestratorGetRunGraph: "ade.orchestrator.getRunGraph", orchestratorStartRun: "ade.orchestrator.startRun", - orchestratorStartRunFromMission: "ade.orchestrator.startRunFromMission", orchestratorStartAttempt: "ade.orchestrator.startAttempt", orchestratorCompleteAttempt: "ade.orchestrator.completeAttempt", orchestratorTickRun: "ade.orchestrator.tickRun", @@ -462,8 +434,6 @@ export const IPC = { orchestratorCleanupTeamResources: "ade.orchestrator.cleanupTeamResources", orchestratorHeartbeatClaims: "ade.orchestrator.heartbeatClaims", orchestratorListTimeline: "ade.orchestrator.listTimeline", - orchestratorGetMissionLogs: "ade.orchestrator.getMissionLogs", - orchestratorExportMissionLogs: "ade.orchestrator.exportMissionLogs", orchestratorGetGateReport: "ade.orchestrator.getGateReport", orchestratorEvent: "ade.orchestrator.event", orchestratorThreadEvent: "ade.orchestrator.threadEvent", @@ -667,8 +637,6 @@ export const IPC = { projectConfigDiffAgainstDisk: "ade.projectConfig.diffAgainstDisk", projectConfigConfirmTrust: "ade.projectConfig.confirmTrust", orchestratorGetWorkerStates: "ade.orchestrator.getWorkerStates", - orchestratorStartMissionRun: "ade.orchestrator.startMissionRun", - orchestratorSteerMission: "ade.orchestrator.steerMission", orchestratorGetModelCapabilities: "ade.orchestrator.getModelCapabilities", orchestratorGetTeamMembers: "ade.orchestrator.getTeamMembers", orchestratorGetTeamRuntimeState: "ade.orchestrator.getTeamRuntimeState", @@ -682,22 +650,14 @@ export const IPC = { orchestratorListWorkerDigests: "ade.orchestrator.listWorkerDigests", orchestratorGetContextCheckpoint: "ade.orchestrator.getContextCheckpoint", orchestratorListLaneDecisions: "ade.orchestrator.listLaneDecisions", - orchestratorGetMissionMetrics: "ade.orchestrator.getMissionMetrics", - orchestratorSetMissionMetricsConfig: - "ade.orchestrator.setMissionMetricsConfig", orchestratorGetExecutionPlanPreview: "ade.orchestrator.getExecutionPlanPreview", - orchestratorGetMissionStateDocument: - "ade.orchestrator.getMissionStateDocument", orchestratorListArtifacts: "ade.orchestrator.listArtifacts", orchestratorListWorkerCheckpoints: "ade.orchestrator.listWorkerCheckpoints", orchestratorGetPromptInspector: "ade.orchestrator.getPromptInspector", orchestratorGetPlanningPromptPreview: "ade.orchestrator.getPlanningPromptPreview", orchestratorGetCheckpointStatus: "ade.orchestrator.getCheckpointStatus", - orchestratorGetMissionBudgetStatus: "ade.orchestrator.getMissionBudgetStatus", - orchestratorGetMissionBudgetTelemetry: - "ade.orchestrator.getMissionBudgetTelemetry", orchestratorSendAgentMessage: "ade.orchestrator.sendAgentMessage", orchestratorGetGlobalChat: "ade.orchestrator.getGlobalChat", orchestratorDeliverMessage: "ade.orchestrator.deliverMessage", diff --git a/apps/desktop/src/shared/linearWorkflowPresets.test.ts b/apps/desktop/src/shared/linearWorkflowPresets.test.ts index 2a3e04e43..eebd58d92 100644 --- a/apps/desktop/src/shared/linearWorkflowPresets.test.ts +++ b/apps/desktop/src/shared/linearWorkflowPresets.test.ts @@ -31,7 +31,6 @@ describe("linearWorkflowPresets", () => { const targetTypes: LinearWorkflowTargetType[] = [ "employee_session", "worker_run", - "mission", "pr_resolution", "review_gate", ]; @@ -51,7 +50,6 @@ describe("linearWorkflowPresets", () => { describe("defaultCompletionContract", () => { it("returns correct contracts for each target type", () => { - expect(defaultCompletionContract("mission")).toBe("wait_for_runtime_success"); expect(defaultCompletionContract("pr_resolution")).toBe("wait_for_pr_created"); expect(defaultCompletionContract("employee_session")).toBe("wait_for_explicit_completion"); expect(defaultCompletionContract("worker_run")).toBe("wait_for_explicit_completion"); @@ -85,7 +83,6 @@ describe("reviewReadyWhenForContract", () => { describe("defaultWorkflowName", () => { it("returns human-readable names for each target type", () => { expect(defaultWorkflowName("employee_session")).toContain("employee"); - expect(defaultWorkflowName("mission")).toContain("Mission"); expect(defaultWorkflowName("worker_run")).toContain("Worker"); expect(defaultWorkflowName("pr_resolution")).toContain("PR"); expect(defaultWorkflowName("review_gate")).toContain("review"); @@ -94,22 +91,16 @@ describe("defaultWorkflowName", () => { describe("resolveWorkflowTargetWaitStatus", () => { it("returns undefined for non-wait_for_target_status steps", () => { - const workflow = createWorkflowPreset("mission"); + const workflow = createWorkflowPreset("employee_session"); const launchStep = workflow.steps.find((s) => s.type === "launch_target")!; expect(resolveWorkflowTargetWaitStatus(workflow, launchStep)).toBeUndefined(); }); it("returns undefined when no step is provided", () => { - const workflow = createWorkflowPreset("mission"); + const workflow = createWorkflowPreset("employee_session"); expect(resolveWorkflowTargetWaitStatus(workflow, undefined)).toBeUndefined(); }); - it("returns runtime_completed for mission wait steps", () => { - const workflow = createWorkflowPreset("mission"); - const waitStep = workflow.steps.find((s) => s.type === "wait_for_target_status")!; - expect(resolveWorkflowTargetWaitStatus(workflow, waitStep)).toBe("runtime_completed"); - }); - it("returns explicit_completion for employee session wait steps", () => { const workflow = createWorkflowPreset("employee_session"); const waitStep = workflow.steps.find((s) => s.type === "wait_for_target_status")!; @@ -121,7 +112,6 @@ describe("createWorkflowPreset", () => { const targetTypes: LinearWorkflowTargetType[] = [ "employee_session", "worker_run", - "mission", "pr_resolution", "review_gate", ]; @@ -155,7 +145,7 @@ describe("createWorkflowPreset", () => { }); it("accepts custom options", () => { - const workflow = createWorkflowPreset("mission", { + const workflow = createWorkflowPreset("worker_run", { id: "custom-id", name: "Custom Name", description: "Custom description", @@ -242,7 +232,7 @@ describe("rebuildWorkflowSteps", () => { }); it("always ends with complete_issue step", () => { - const targetTypes: LinearWorkflowTargetType[] = ["employee_session", "mission", "worker_run", "pr_resolution", "review_gate"]; + const targetTypes: LinearWorkflowTargetType[] = ["employee_session", "worker_run", "pr_resolution", "review_gate"]; for (const targetType of targetTypes) { const workflow = createWorkflowPreset(targetType); const rebuilt = rebuildWorkflowSteps(workflow, {}); diff --git a/apps/desktop/src/shared/linearWorkflowPresets.ts b/apps/desktop/src/shared/linearWorkflowPresets.ts index ed7f460ae..883844897 100644 --- a/apps/desktop/src/shared/linearWorkflowPresets.ts +++ b/apps/desktop/src/shared/linearWorkflowPresets.ts @@ -40,7 +40,6 @@ const visualManagedStepTypes = new Set<LinearWorkflowStep["type"]>([ function workflowIdForTargetType(targetType: LinearWorkflowTargetType): string { if (targetType === "employee_session") return "assigned-employee-session"; - if (targetType === "mission") return "assigned-mission-run"; if (targetType === "worker_run") return "assigned-worker-run"; if (targetType === "pr_resolution") return "assigned-pr-resolution"; return "assigned-review-gate"; @@ -48,7 +47,6 @@ function workflowIdForTargetType(targetType: LinearWorkflowTargetType): string { export function defaultWorkflowName(targetType: LinearWorkflowTargetType): string { if (targetType === "employee_session") return "Assigned employee -> review handoff"; - if (targetType === "mission") return "Mission autopilot"; if (targetType === "worker_run") return "Worker run"; if (targetType === "pr_resolution") return "PR resolution"; return "Human review gate"; @@ -67,7 +65,7 @@ function defaultLaunchStep(targetType: LinearWorkflowTargetType): LinearWorkflow ? "Launch PR workflow" : targetType === "worker_run" ? "Launch worker run" - : "Launch mission", + : "Create review gate", }; } @@ -97,7 +95,6 @@ function defaultNotificationStep(targetType: LinearWorkflowTargetType): LinearWo } export function defaultCompletionContract(targetType: LinearWorkflowTargetType): LinearWorkflowCompletionContract { - if (targetType === "mission") return "wait_for_runtime_success"; if (targetType === "pr_resolution") return "wait_for_pr_created"; if (targetType === "employee_session" || targetType === "worker_run") { return "wait_for_explicit_completion"; @@ -122,9 +119,6 @@ export function resolveWorkflowTargetWaitStatus( step?: LinearWorkflowStep, ): LinearWorkflowStep["targetStatus"] { if (!step || step.type !== "wait_for_target_status") return undefined; - if (workflow.target.type === "mission") { - return step.targetStatus ?? "runtime_completed"; - } if (!step.targetStatus || step.targetStatus === "completed") { return "explicit_completion"; } @@ -171,12 +165,6 @@ export function createWorkflowPreset( laneSelection: "fresh_issue_lane", prTiming: "after_target_complete", } - : targetType === "mission" - ? { - type: targetType, - runMode: "autopilot", - missionTemplate: "default", - } : { type: targetType, runMode: "autopilot", @@ -198,7 +186,7 @@ export function createWorkflowPreset( id: "wait", type: "wait_for_target_status", name: "Wait for delegated work", - targetStatus: targetType === "mission" ? "runtime_completed" : "explicit_completion", + targetStatus: "explicit_completion", }); } steps.push({ id: "wait-pr", type: "wait_for_pr", name: "Wait for PR" }); @@ -209,9 +197,7 @@ export function createWorkflowPreset( name: "Wait for delegated work", targetStatus: completionContract === "wait_for_runtime_success" - ? targetType === "mission" - ? "runtime_completed" - : "runtime_completed" + ? "runtime_completed" : "explicit_completion", }); } @@ -340,7 +326,7 @@ export function rebuildWorkflowSteps( id: "wait", type: "wait_for_target_status", name: "Wait for delegated work", - targetStatus: workflow.target.type === "mission" ? "runtime_completed" : "explicit_completion", + targetStatus: "explicit_completion", }; if (nextPlan.completionContract === "wait_for_runtime_success") { steps.push({ @@ -359,7 +345,7 @@ export function rebuildWorkflowSteps( ) { steps.push({ ...targetWaitStep, - targetStatus: workflow.target.type === "mission" ? "runtime_completed" : "explicit_completion", + targetStatus: "explicit_completion", }); } } diff --git a/apps/desktop/src/shared/modelProfiles.test.ts b/apps/desktop/src/shared/modelProfiles.test.ts index 1819b1538..79636f9fc 100644 --- a/apps/desktop/src/shared/modelProfiles.test.ts +++ b/apps/desktop/src/shared/modelProfiles.test.ts @@ -1,19 +1,15 @@ import { describe, expect, it } from "vitest"; import { ALL_MODELS, - BUILT_IN_PROFILES, CLAUDE_MODELS, CLAUDE_THINKING_LEVELS, CODEX_MODELS, CODEX_THINKING_LEVELS, findModel, getModelsForProvider, - getProfileById, getThinkingLevels, MODEL_PRICING, modelConfigToServiceModel, - ORCHESTRATOR_CALL_TYPES, - resolveCallTypeModel, thinkingLevelToReasoningEffort, updateModelPricing, } from "./modelProfiles"; @@ -143,144 +139,6 @@ describe("thinking levels", () => { }); }); -// --------------------------------------------------------------------------- -// ORCHESTRATOR_CALL_TYPES -// --------------------------------------------------------------------------- - -describe("ORCHESTRATOR_CALL_TYPES", () => { - it("contains coordinator and chat_response entries", () => { - const keys = ORCHESTRATOR_CALL_TYPES.map((c) => c.key); - expect(keys).toContain("coordinator"); - expect(keys).toContain("chat_response"); - }); - - it("every entry has a label, description, and defaultProvider", () => { - for (const ct of ORCHESTRATOR_CALL_TYPES) { - expect(typeof ct.label).toBe("string"); - expect(ct.label.length).toBeGreaterThan(0); - expect(typeof ct.description).toBe("string"); - expect(ct.description.length).toBeGreaterThan(0); - expect(typeof ct.defaultProvider).toBe("string"); - } - }); -}); - -// --------------------------------------------------------------------------- -// Built-in profiles -// --------------------------------------------------------------------------- - -describe("BUILT_IN_PROFILES", () => { - it("has exactly 5 built-in profiles", () => { - expect(BUILT_IN_PROFILES).toHaveLength(5); - }); - - const expectedIds = ["standard", "fast-cheap", "max-quality", "codex-only", "claude-only"]; - - it.each(expectedIds)("contains the '%s' profile", (id) => { - const profile = BUILT_IN_PROFILES.find((p) => p.id === id); - expect(profile).toBeDefined(); - expect(profile!.isBuiltIn).toBe(true); - }); - - it("every profile has a valid structure", () => { - for (const profile of BUILT_IN_PROFILES) { - expect(typeof profile.id).toBe("string"); - expect(typeof profile.name).toBe("string"); - expect(typeof profile.description).toBe("string"); - expect(profile.isBuiltIn).toBe(true); - expect(profile.orchestratorModel).toBeDefined(); - expect(profile.orchestratorModel.modelId).toBeTruthy(); - expect(profile.phaseDefaults).toBeDefined(); - expect(profile.phaseDefaults.planning).toBeDefined(); - expect(profile.phaseDefaults.implementation).toBeDefined(); - expect(profile.phaseDefaults.testing).toBeDefined(); - expect(profile.phaseDefaults.validation).toBeDefined(); - expect(profile.phaseDefaults.codeReview).toBeDefined(); - expect(profile.phaseDefaults.testReview).toBeDefined(); - expect(profile.intelligenceConfig).toBeDefined(); - } - }); - - it("codex-only profile uses only codex-provider models", () => { - const profile = getProfileById("codex-only")!; - expect(profile.orchestratorModel.provider).toBe("codex"); - expect(profile.phaseDefaults.planning.provider).toBe("codex"); - expect(profile.phaseDefaults.implementation.provider).toBe("codex"); - }); - - it("claude-only profile uses only claude-provider models", () => { - const profile = getProfileById("claude-only")!; - expect(profile.orchestratorModel.provider).toBe("claude"); - expect(profile.phaseDefaults.planning.provider).toBe("claude"); - expect(profile.phaseDefaults.implementation.provider).toBe("claude"); - }); -}); - -// --------------------------------------------------------------------------- -// getProfileById -// --------------------------------------------------------------------------- - -describe("getProfileById", () => { - it("returns the standard profile by id", () => { - const profile = getProfileById("standard"); - expect(profile).toBeDefined(); - expect(profile!.name).toBe("Standard"); - }); - - it("returns undefined for an unknown profile id", () => { - expect(getProfileById("nonexistent")).toBeUndefined(); - }); - - it("returns undefined for an empty string", () => { - expect(getProfileById("")).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// resolveCallTypeModel -// --------------------------------------------------------------------------- - -describe("resolveCallTypeModel", () => { - it("returns explicit config when present in intelligenceConfig", () => { - const config = { provider: "codex" as const, modelId: "openai/gpt-5.4", thinkingLevel: "high" as const }; - const result = resolveCallTypeModel("coordinator", { coordinator: config }); - expect(result).toBe(config); - }); - - it("falls back to fallbackModel when intelligenceConfig has no entry", () => { - const fallback = { provider: "claude" as const, modelId: "anthropic/claude-haiku-4-5", thinkingLevel: "low" as const }; - const result = resolveCallTypeModel("coordinator", {}, fallback); - expect(result).toBe(fallback); - }); - - it("falls back to fallbackModel when intelligenceConfig is null", () => { - const fallback = { provider: "codex" as const, modelId: "openai/gpt-5.4" }; - const result = resolveCallTypeModel("chat_response", null, fallback); - expect(result).toBe(fallback); - }); - - it("falls back to fallbackModel when intelligenceConfig is undefined", () => { - const fallback = { provider: "codex" as const, modelId: "openai/gpt-5.4" }; - const result = resolveCallTypeModel("coordinator", undefined, fallback); - expect(result).toBe(fallback); - }); - - it("returns ultimate fallback (Claude Sonnet) when no config or fallback", () => { - const result = resolveCallTypeModel("coordinator"); - expect(result.modelId).toBe("anthropic/claude-sonnet-4-6"); - expect(result.provider).toBe("claude"); - }); - - it("returns ultimate fallback when both intelligenceConfig and fallback are null", () => { - const result = resolveCallTypeModel("chat_response", null, null); - expect(result.modelId).toBe("anthropic/claude-sonnet-4-6"); - }); -}); - -// --------------------------------------------------------------------------- -// modelConfigToServiceModel -// --------------------------------------------------------------------------- - describe("modelConfigToServiceModel", () => { it("returns the modelId when it is a non-empty string", () => { expect(modelConfigToServiceModel({ modelId: "anthropic/claude-opus-4-7" })).toBe("anthropic/claude-opus-4-7"); diff --git a/apps/desktop/src/shared/modelProfiles.ts b/apps/desktop/src/shared/modelProfiles.ts index 9d21761ac..cd49ef92a 100644 --- a/apps/desktop/src/shared/modelProfiles.ts +++ b/apps/desktop/src/shared/modelProfiles.ts @@ -1,9 +1,6 @@ import type { ModelConfig, ModelProvider, - MissionModelProfile, - OrchestratorCallType, - OrchestratorIntelligenceConfig, ThinkingLevel } from "./types"; import { @@ -34,7 +31,7 @@ function providerFromFamily(family: ModelDescriptor["family"]): ModelProvider { return family; } -/** Map a registry descriptor to a ModelEntry for the missions UI */ +/** Map a registry descriptor to a model picker entry. */ function descriptorToEntry(d: ModelDescriptor, overrides?: { recommended?: boolean }): ModelEntry { const provider: ModelProvider = providerFromFamily(d.family); return { @@ -106,176 +103,6 @@ export function getThinkingLevels(provider: ModelProvider): ThinkingOption[] { ]; } -// ───────────────────────────────────────────────────── -// Orchestrator call type metadata (for UI) -// ───────────────────────────────────────────────────── - -export type CallTypeInfo = { - key: OrchestratorCallType; - label: string; - description: string; - defaultProvider: ModelProvider; -}; - -export const ORCHESTRATOR_CALL_TYPES: CallTypeInfo[] = [ - { - key: "coordinator", - label: "Coordinator", - description: "Persistent AI session that manages the mission — spawns workers, makes decisions, handles failures", - defaultProvider: "claude", - }, - { - key: "chat_response", - label: "Chat Response", - description: "Handles user chat messages when no coordinator is active", - defaultProvider: "claude", - }, -]; - -// ───────────────────────────────────────────────────── -// Built-in model profiles -// ───────────────────────────────────────────────────── - -const CLAUDE_SONNET: ModelConfig = { provider: "claude", modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }; -const CLAUDE_HAIKU: ModelConfig = { provider: "claude", modelId: "anthropic/claude-haiku-4-5", thinkingLevel: "low" }; -const CLAUDE_OPUS: ModelConfig = { provider: "claude", modelId: "anthropic/claude-opus-4-7", thinkingLevel: "high" }; -const CODEX_STANDARD: ModelConfig = { provider: "codex", modelId: DEFAULT_CODEX_MODEL_ID, thinkingLevel: "medium" }; -const CODEX_MINI: ModelConfig = { provider: "codex", modelId: "openai/gpt-5.4-mini", thinkingLevel: "low" }; - -export const BUILT_IN_PROFILES: MissionModelProfile[] = [ - { - id: "standard", - name: "Standard", - description: "Balanced setup: Codex for implementation/review/verification, Claude for orchestration and planning.", - isBuiltIn: true, - orchestratorModel: CLAUDE_SONNET, - decisionTimeoutCapHours: 24, - phaseDefaults: { - planning: CLAUDE_SONNET, - implementation: CODEX_STANDARD, - testing: CODEX_STANDARD, - validation: CODEX_STANDARD, - codeReview: CLAUDE_SONNET, - testReview: CODEX_STANDARD, - prReview: CODEX_STANDARD, - }, - intelligenceConfig: { - coordinator: CLAUDE_SONNET, - chat_response: CLAUDE_SONNET, - } - }, - { - id: "fast-cheap", - name: "Fast & Cheap", - description: "Minimizes cost and latency. Uses lightweight models everywhere. Good for simple tasks.", - isBuiltIn: true, - orchestratorModel: CLAUDE_HAIKU, - decisionTimeoutCapHours: 24, - phaseDefaults: { - planning: CLAUDE_HAIKU, - implementation: CODEX_MINI, - testing: CODEX_MINI, - validation: CODEX_MINI, - codeReview: CLAUDE_HAIKU, - testReview: CODEX_MINI, - prReview: CODEX_MINI, - }, - intelligenceConfig: { - coordinator: CLAUDE_HAIKU, - chat_response: CLAUDE_HAIKU, - }, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 5, - weeklyThresholdUsd: 25, - } - }, - { - id: "max-quality", - name: "Maximum Quality", - description: "Uses the most capable models for every task. Higher cost, best results for complex projects.", - isBuiltIn: true, - orchestratorModel: CLAUDE_OPUS, - decisionTimeoutCapHours: 24, - phaseDefaults: { - planning: CLAUDE_OPUS, - implementation: { provider: "codex", modelId: DEFAULT_CODEX_MODEL_ID, thinkingLevel: "high" }, - testing: CODEX_STANDARD, - validation: CLAUDE_OPUS, - codeReview: CLAUDE_OPUS, - testReview: CLAUDE_SONNET, - prReview: CODEX_STANDARD, - }, - intelligenceConfig: { - coordinator: CLAUDE_OPUS, - chat_response: CLAUDE_SONNET, - } - }, - { - id: "codex-only", - name: "Codex Only", - description: "Uses Codex (OpenAI) for everything. No Claude API calls. Good if you only have OpenAI access.", - isBuiltIn: true, - orchestratorModel: CODEX_STANDARD, - decisionTimeoutCapHours: 24, - phaseDefaults: { - planning: CODEX_STANDARD, - implementation: CODEX_STANDARD, - testing: CODEX_STANDARD, - validation: CODEX_STANDARD, - codeReview: CODEX_STANDARD, - testReview: CODEX_STANDARD, - prReview: CODEX_STANDARD, - }, - intelligenceConfig: { - coordinator: CODEX_STANDARD, - chat_response: CODEX_STANDARD, - } - }, - { - id: "claude-only", - name: "Claude Only", - description: "Uses Claude for everything. No Codex/OpenAI API calls. Good if you only have Anthropic access.", - isBuiltIn: true, - orchestratorModel: CLAUDE_SONNET, - decisionTimeoutCapHours: 24, - phaseDefaults: { - planning: CLAUDE_SONNET, - implementation: CLAUDE_SONNET, - testing: CLAUDE_SONNET, - validation: CLAUDE_SONNET, - codeReview: CLAUDE_SONNET, - testReview: CLAUDE_HAIKU, - prReview: CLAUDE_SONNET, - }, - intelligenceConfig: { - coordinator: CLAUDE_SONNET, - chat_response: CLAUDE_SONNET, - } - } -]; - -export function getProfileById(id: string): MissionModelProfile | undefined { - return BUILT_IN_PROFILES.find((p) => p.id === id); -} - -// ───────────────────────────────────────────────────── -// Resolution helpers -// ───────────────────────────────────────────────────── - -/** Resolve the model config for a specific orchestrator call type */ -export function resolveCallTypeModel( - callType: OrchestratorCallType, - intelligenceConfig?: OrchestratorIntelligenceConfig | null, - fallbackModel?: ModelConfig | null -): ModelConfig { - const explicit = intelligenceConfig?.[callType]; - if (explicit) return explicit; - if (fallbackModel) return fallbackModel; - // Ultimate fallback: Claude Sonnet - return CLAUDE_SONNET; -} - /** Convert a ModelConfig to the model string used by aiIntegrationService */ export function modelConfigToServiceModel(config: ModelConfig): string { const modelId = config.modelId?.trim(); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index e4d19917a..962d0ceba 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -53,7 +53,7 @@ export type ModelDescriptor = { inputPricePer1M?: number; /** Price per million output tokens (USD). Used for cost estimation. */ outputPricePer1M?: number; - /** Curated cost tier for UI display (missions model selector) */ + /** Curated cost tier for UI display. */ costTier?: "low" | "medium" | "high" | "very_high"; /** ADE-owned safety/tooling profile for local and experimental models. */ harnessProfile?: LocalModelHarnessProfile; diff --git a/apps/desktop/src/shared/proofArtifacts.ts b/apps/desktop/src/shared/proofArtifacts.ts index 895bdff7d..5f0a5187c 100644 --- a/apps/desktop/src/shared/proofArtifacts.ts +++ b/apps/desktop/src/shared/proofArtifacts.ts @@ -1,16 +1,5 @@ -import type { MissionArtifactType, ValidationEvidenceRequirement } from "./types/missions"; -import type { MissionCloseoutRequirementKey, OrchestratorArtifactKind } from "./types/orchestrator"; - type ArtifactMetadataLike = Record<string, unknown> | null | undefined; -const PROOF_ARTIFACT_KEYS = new Set<MissionCloseoutRequirementKey>([ - "screenshot", - "browser_verification", - "browser_trace", - "video_recording", - "console_logs", -]); - export const COMPUTER_USE_ARTIFACT_KINDS = [ "screenshot", "browser_verification", @@ -19,7 +8,36 @@ export const COMPUTER_USE_ARTIFACT_KINDS = [ "console_logs", ] as const; -const CLOSEOUT_ARTIFACT_KEY_SET = new Set<MissionCloseoutRequirementKey>([ +export type ComputerUseProofArtifactKind = (typeof COMPUTER_USE_ARTIFACT_KINDS)[number]; + +export type ReportArtifactKey = + | "planning_document" + | "research_summary" + | "changed_files_summary" + | "test_report" + | "implementation_summary" + | "validation_verdict" + | "screenshot" + | "browser_verification" + | "browser_trace" + | "video_recording" + | "console_logs" + | "risk_notes" + | "pr_url" + | "proposal_url" + | "review_summary" + | "final_outcome_summary" + | "summary" + | "link" + | "note" + | "patch" + | "plan" + | "implementation_pr" + | "feature_branch"; + +const PROOF_ARTIFACT_KEYS = new Set<ComputerUseProofArtifactKind>(COMPUTER_USE_ARTIFACT_KINDS); + +const REPORT_ARTIFACT_KEY_SET = new Set<ReportArtifactKey>([ "planning_document", "research_summary", "changed_files_summary", @@ -36,21 +54,13 @@ const CLOSEOUT_ARTIFACT_KEY_SET = new Set<MissionCloseoutRequirementKey>([ "proposal_url", "review_summary", "final_outcome_summary", -]); - -const MISSION_ARTIFACT_TYPE_SET = new Set<MissionArtifactType>([ "summary", - "pr", "link", "note", "patch", "plan", - "test_report", - "screenshot", - "browser_verification", - "browser_trace", - "video_recording", - "console_logs", + "implementation_pr", + "feature_branch", ]); function normalizeArtifactToken(value: string | null | undefined): string { @@ -77,7 +87,7 @@ function metadataCandidates(metadata: ArtifactMetadataLike): string[] { .filter((value): value is string => typeof value === "string" && value.trim().length > 0); } -function canonicalizeProofArtifactKey(value: string | null | undefined): Extract<MissionArtifactType, "screenshot" | "browser_verification" | "browser_trace" | "video_recording" | "console_logs"> | null { +function canonicalizeProofArtifactKey(value: string | null | undefined): ComputerUseProofArtifactKind | null { const token = normalizeArtifactToken(value); if (!token) return null; if ( @@ -133,45 +143,27 @@ function canonicalizeProofArtifactKey(value: string | null | undefined): Extract export function normalizeComputerUseArtifactKind( value: string | null | undefined, -): Extract<MissionArtifactType, "screenshot" | "browser_verification" | "browser_trace" | "video_recording" | "console_logs"> | null { +): ComputerUseProofArtifactKind | null { return canonicalizeProofArtifactKey(value); } -function resolveMissionArtifactAlias(value: string | null | undefined): MissionArtifactType | null { +function resolveReportArtifactAlias(value: string | null | undefined): ReportArtifactKey | null { const token = normalizeArtifactToken(value); if (!token) return null; const proofKey = canonicalizeProofArtifactKey(token); if (proofKey) return proofKey; - if (token === "pull_request" || token === "pr_link") return "pr"; - if (token === "planning_document" || token === "mission_plan") return "plan"; + if (token === "pull_request" || token === "pr" || token === "pr_link") return "implementation_pr"; if (token === "test_results") return "test_report"; - if (MISSION_ARTIFACT_TYPE_SET.has(token as MissionArtifactType)) return token as MissionArtifactType; - return null; -} - -function resolveCloseoutAlias(value: string | null | undefined): MissionCloseoutRequirementKey | null { - const token = normalizeArtifactToken(value); - if (!token) return null; - const proofKey = canonicalizeProofArtifactKey(token); - if (proofKey) return proofKey; - if (token === "plan" || token === "mission_plan") return "planning_document"; - if (token === "pr" || token === "pull_request") return "pr_url"; - if (token === "test_results") return "test_report"; - if (CLOSEOUT_ARTIFACT_KEY_SET.has(token as MissionCloseoutRequirementKey)) { - return token as MissionCloseoutRequirementKey; - } + if (token === "branch") return "feature_branch"; + if (REPORT_ARTIFACT_KEY_SET.has(token as ReportArtifactKey)) return token as ReportArtifactKey; return null; } export function isProofEvidenceRequirement( - value: MissionCloseoutRequirementKey | ValidationEvidenceRequirement | string | null | undefined, + value: string | null | undefined, ): boolean { - const key = resolveCloseoutAlias(value); - return key != null && PROOF_ARTIFACT_KEYS.has(key); -} - -export function normalizeMissionArtifactType(value: string): MissionArtifactType { - return resolveMissionArtifactAlias(value) ?? "note"; + const key = resolveReportArtifactAlias(value); + return key != null && PROOF_ARTIFACT_KEYS.has(key as ComputerUseProofArtifactKind); } export function resolveCloseoutRequirementKeyFromArtifact(args: { @@ -179,7 +171,7 @@ export function resolveCloseoutRequirementKeyFromArtifact(args: { artifactKey?: string | null; kind?: string | null; metadata?: ArtifactMetadataLike; -}): MissionCloseoutRequirementKey | null { +}): ReportArtifactKey | null { const candidates = [ args.artifactType, args.artifactKey, @@ -187,7 +179,7 @@ export function resolveCloseoutRequirementKeyFromArtifact(args: { ...metadataCandidates(args.metadata), ]; for (const candidate of candidates) { - const resolved = resolveCloseoutAlias(candidate); + const resolved = resolveReportArtifactAlias(candidate); if (resolved) return resolved; } return null; @@ -205,80 +197,11 @@ export function resolveReportArtifactKey(args: { args.title, ]; for (const candidate of candidates) { - const proofKey = canonicalizeProofArtifactKey(candidate); - if (proofKey) return proofKey; - const missionType = resolveMissionArtifactAlias(candidate); - if (missionType === "plan" || missionType === "summary" || missionType === "link" || missionType === "note" || missionType === "patch" || missionType === "pr" || missionType === "test_report") { - return missionType === "pr" ? "implementation_pr" : missionType; - } - const closeoutKey = resolveCloseoutAlias(candidate); - if (closeoutKey) return closeoutKey; + const artifactKey = resolveReportArtifactAlias(candidate); + if (artifactKey) return artifactKey; } - if (normalizeArtifactToken(args.type) === "branch") return "feature_branch"; - if (normalizeArtifactToken(args.type) === "pull_request") return "implementation_pr"; const fallbackKey = `reported_artifact_${args.index + 1}`; const rawTitle = typeof args.title === "string" ? args.title.trim() : ""; if (!rawTitle.length) return fallbackKey; return normalizeArtifactToken(rawTitle) || fallbackKey; } - -export function resolveReportArtifactMissionType(args: { - type?: string | null; - artifactKey?: string | null; - metadata?: ArtifactMetadataLike; -}): MissionArtifactType | null { - const candidates = [ - args.type, - args.artifactKey, - ...metadataCandidates(args.metadata), - ]; - for (const candidate of candidates) { - const missionType = resolveMissionArtifactAlias(candidate); - if (missionType) return missionType; - } - return null; -} - -export function resolveReportArtifactKind(args: { - type?: string | null; - artifactKey?: string | null; - uri?: string | null; - metadata?: ArtifactMetadataLike; -}): OrchestratorArtifactKind { - const closeoutKey = resolveCloseoutRequirementKeyFromArtifact({ - artifactType: args.type, - artifactKey: args.artifactKey, - metadata: args.metadata, - }); - if (closeoutKey === "screenshot") return "screenshot"; - if (closeoutKey === "video_recording") return "video"; - const rawType = normalizeArtifactToken(args.type); - if (rawType === "branch") return "branch"; - if (rawType === "pr" || rawType === "pull_request") return "pr"; - if (rawType === "test_report" || rawType === "test_results") return "test_report"; - const uri = typeof args.uri === "string" ? args.uri.trim() : ""; - if (closeoutKey === "browser_trace" || closeoutKey === "browser_verification" || closeoutKey === "console_logs") { - return uri.length > 0 ? "file" : "custom"; - } - return uri.length > 0 ? "file" : "custom"; -} - -export function resolveOrchestratorArtifactUri(args: { - kind: string; - value: string; - metadata?: ArtifactMetadataLike; -}): string | null { - const value = args.value.trim(); - const metadataUri = typeof args.metadata?.uri === "string" ? args.metadata.uri.trim() : ""; - if (!value.length) return metadataUri.length > 0 ? metadataUri : null; - if ( - args.kind === "file" - || args.kind === "branch" - || args.kind === "pr" - || args.kind === "screenshot" - || args.kind === "video" - ) { - return value; - } - return metadataUri.length > 0 ? metadataUri : null; -} diff --git a/apps/desktop/src/shared/types/automations.ts b/apps/desktop/src/shared/types/automations.ts index a704c15b8..b745cd1a3 100644 --- a/apps/desktop/src/shared/types/automations.ts +++ b/apps/desktop/src/shared/types/automations.ts @@ -17,10 +17,10 @@ import type { AutomationTrigger, AutomationTriggerType, AutomationVerification, + AiPermissionSettings, RunAdeActionConfig, } from "./config"; -import type { MissionPermissionConfig } from "./missions"; -import type { MissionModelConfig, ModelConfig } from "./models"; +import type { ModelConfig } from "./models"; import type { AgentChatSessionSummary } from "./chat"; export type AutomationRunStatus = @@ -43,7 +43,6 @@ export type AutomationRun = { id: string; automationId: string; chatSessionId: string | null; - missionId: string | null; triggerType: AutomationTriggerType; startedAt: string; endedAt: string | null; @@ -232,10 +231,9 @@ export type AutomationDraftAction = targetLaneId?: string | null; modelConfig?: ModelConfig; codexFastMode?: boolean; - permissionConfig?: MissionPermissionConfig; + permissionConfig?: AiPermissionSettings; }) - | (AutomationDraftActionBase & { type: "ade-action"; adeAction: RunAdeActionConfig }) - | (AutomationDraftActionBase & { type: "launch-mission"; missionTitle?: string; targetLaneId?: string | null }); + | (AutomationDraftActionBase & { type: "ade-action"; adeAction: RunAdeActionConfig }); export type AutomationRuleDraft = { id?: string | null; @@ -248,8 +246,8 @@ export type AutomationRuleDraft = { trigger: AutomationTrigger; execution?: AutomationExecution; executor: AutomationExecutor; - modelConfig?: MissionModelConfig; - permissionConfig?: MissionPermissionConfig; + modelConfig?: ModelConfig; + permissionConfig?: AiPermissionSettings; templateId?: string; prompt?: string; reviewProfile: AutomationReviewProfile; @@ -354,7 +352,7 @@ export type AutomationSaveDraftResult = { export type AutomationSimulationAction = { index: number; - type: AutomationActionType | "mission-dispatch" | "review-summary" | "queue-result"; + type: AutomationActionType | "review-summary" | "queue-result"; summary: string; commandPreview?: string; cwdPreview?: string; diff --git a/apps/desktop/src/shared/types/budget.ts b/apps/desktop/src/shared/types/budget.ts deleted file mode 100644 index bcabcc233..000000000 --- a/apps/desktop/src/shared/types/budget.ts +++ /dev/null @@ -1,143 +0,0 @@ -// --------------------------------------------------------------------------- -// Budget types -// --------------------------------------------------------------------------- - -import type { ModelProvider } from "./models"; - -/** Smart token budget configuration */ -export type SmartBudgetConfig = { - enabled: boolean; - /** Cost threshold (USD) for 5-hour window before steering kicks in */ - fiveHourThresholdUsd: number; - /** Cost threshold (USD) for weekly window before steering kicks in */ - weeklyThresholdUsd: number; - /** Current 5-hour spend (populated at runtime, not persisted) */ - currentFiveHourSpendUsd?: number; - /** Current weekly spend (populated at runtime, not persisted) */ - currentWeeklySpendUsd?: number; - /** Per-provider tier ceilings (user-set, keyed by ModelProvider) */ - providerLimits?: Partial<Record<ModelProvider, import("./models").ProviderBudgetLimits>>; - /** Hard stop: pause mission at this % of 5-hour limit (e.g. 80 = 80%) */ - fiveHourHardStopPercent?: number; - /** Hard stop: pause mission at this % of weekly limit (e.g. 95 = 95%) */ - weeklyHardStopPercent?: number; - /** Hard stop: max API-key spend in USD before pausing */ - apiKeyMaxSpendUsd?: number; - /** Usage % threshold at which the orchestrator downgrades workers to a cheaper model tier */ - modelDowngradeThresholdPct?: number; -}; - -export type MissionBudgetPressure = "normal" | "warning" | "critical"; - -export type MissionBudgetScopeSnapshot = { - maxTokens?: number | null; - maxTimeMs?: number | null; - maxCostUsd?: number | null; - usedTokens: number; - usedTimeMs: number; - usedCostUsd: number; - remainingTokens?: number | null; - remainingTimeMs?: number | null; - remainingCostUsd?: number | null; -}; - -export type MissionPhaseBudgetSnapshot = MissionBudgetScopeSnapshot & { - phaseKey: string; - phaseName: string; - stepCount: number; -}; - -export type MissionWorkerBudgetSnapshot = MissionBudgetScopeSnapshot & { - stepId: string; - stepKey: string; - title: string; - phaseKey: string; - phaseName: string; -}; - -export type MissionBudgetProviderWindow = { - usedTokens: number; - limitTokens: number | null; - usedPct: number | null; - usedCostUsd: number; - timeUntilResetMs: number | null; -}; - -export type MissionBudgetProviderSnapshot = { - provider: string; - fiveHour: MissionBudgetProviderWindow; - weekly: MissionBudgetProviderWindow; -}; - -export type MissionBudgetHardCapStatus = { - fiveHourHardStopPercent: number | null; - weeklyHardStopPercent: number | null; - apiKeyMaxSpendUsd: number | null; - apiKeySpentUsd: number; - fiveHourTriggered: boolean; - weeklyTriggered: boolean; - apiKeyTriggered: boolean; -}; - -export type MissionBudgetRateLimit = { - provider: string; - remainingTokens: number | null; - resetAt: string | null; - source: "runtime" | "claude-local" | "unknown"; -}; - -export type MissionBudgetForecast = { - lowCostUsd: number | null; - medianCostUsd: number | null; - highCostUsd: number | null; - lowDurationMs: number | null; - medianDurationMs: number | null; - highDurationMs: number | null; - confidence: number | null; - sampleSize: number; - basis: string; -}; - -export type MissionBudgetSnapshot = { - missionId: string; - runId: string | null; - computedAt: string; - mode: "subscription" | "api-key"; - pressure: MissionBudgetPressure; - mission: MissionBudgetScopeSnapshot; - perPhase: MissionPhaseBudgetSnapshot[]; - perWorker: MissionWorkerBudgetSnapshot[]; - perProvider: MissionBudgetProviderSnapshot[]; - hardCaps: MissionBudgetHardCapStatus; - activeWorkers: number; - recommendation: string; - burnRateUsdPerHour: number | null; - forecast: MissionBudgetForecast | null; - estimatedRemainingCapacity: { - steps: number | null; - durationMs: number | null; - }; - rateLimits: MissionBudgetRateLimit[]; - dataSources: string[]; -}; - -export type GetMissionBudgetStatusArgs = { - missionId: string; - runId?: string | null; - /** - * Set to false for lightweight renderer reads that should not synchronously - * scan local Claude/Codex JSONL telemetry. - */ - includeCliTelemetry?: boolean | null; -}; - -export type GetMissionBudgetTelemetryArgs = { - providers?: string[]; - providerLimits?: Partial<Record<ModelProvider, import("./models").ProviderBudgetLimits>>; -}; - -export type MissionBudgetTelemetrySnapshot = { - computedAt: string; - perProvider: MissionBudgetProviderSnapshot[]; - dataSources: string[]; -}; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 6850dff6c..1d74ecbb5 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -6,20 +6,107 @@ import type { ModelId } from "./core"; import type { CtoCapabilityMode } from "./cto"; import type { FileDiff } from "./git"; import type { LaneLinearIssue } from "./lanes"; -import type { DelegationContract } from "./orchestrator"; export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | (string & {}); export type AgentChatSessionStatus = "active" | "idle" | "ended"; export type AgentChatSessionProfile = "light" | "workflow"; -export type ChatSurfaceMode = "standard" | "resolver" | "mission-thread" | "mission-feed"; +export type DelegationMode = "exclusive" | "bounded_parallel" | "recovery"; + +export type DelegationIntent = + | "planner" + | "implementation" + | "validation" + | "specialist" + | "subagent" + | "parallel_subtasks" + | "recovery"; + +export type DelegationScope = { + kind: "phase" | "step" | "worker" | "batch"; + key: string; + label?: string | null; +}; + +export type DelegationContractStatus = + | "launching" + | "active" + | "completed" + | "failed" + | "launch_failed" + | "recovering" + | "blocked" + | "canceled"; + +export type DelegationLaunchState = + | "awaiting_context" + | "fetching_context" + | "awaiting_worker_launch" + | "launching_worker" + | "waiting_on_worker" + | "recovering" + | "completed" + | "blocked"; + +export type CoordinatorCapability = + | "observe" + | "fetch_project_context" + | "spawn_top_level_worker" + | "spawn_nested_worker" + | "spawn_parallel_workers" + | "read_repo" + | "message_workers" + | "ask_user" + | "run_control" + | "update_state"; + +export type DelegationFailureCategory = + | "run_context_bug" + | "provider_unreachable" + | "permission_denied" + | "tool_schema_error" + | "native_tool_violation" + | "unknown"; + +export type DelegationContract = { + schemaVersion: 1; + contractId: string; + runId: string; + ownerKind: "coordinator"; + workerIntent: DelegationIntent; + mode: DelegationMode; + scope: DelegationScope; + phaseKey: string | null; + status: DelegationContractStatus; + launchState: DelegationLaunchState | null; + activeWorkerIds: string[]; + coordinatorCapabilities: CoordinatorCapability[]; + launchPolicy: { + maxLaunchAttempts: number; + }; + failurePolicy: { + retryLimit: number; + escalation: "intervention" | "retry" | "stop"; + }; + batchId?: string | null; + parentContractId?: string | null; + failure?: { + category: DelegationFailureCategory; + message: string; + retryAfterMs?: number | null; + } | null; + createdAt: string; + updatedAt: string; +}; + +export type ChatSurfaceMode = "standard" | "resolver"; export type ChatSurfaceProfile = "standard" | "persistent_identity"; export type ChatModelSwitchPolicy = "same-family-after-launch" | "any-after-launch"; export type ChatSurfaceChipTone = "accent" | "success" | "warning" | "danger" | "info" | "muted"; -export type OperatorNavigationSurface = "work" | "missions" | "lanes" | "cto"; +export type OperatorNavigationSurface = "work" | "lanes" | "cto"; export type OperatorNavigationSuggestion = { surface: OperatorNavigationSurface; @@ -27,7 +114,6 @@ export type OperatorNavigationSuggestion = { href: string; laneId?: string | null; sessionId?: string | null; - missionId?: string | null; }; export type ChatSurfaceChip = { @@ -46,7 +132,6 @@ export type ChatSurfacePresentation = { messagePlaceholder?: string | null; chips?: ChatSurfaceChip[]; showMcpStatus?: boolean; - rewriteMissionControlTextTools?: boolean; }; export type AgentChatApprovalDecision = "accept" | "accept_for_session" | "decline" | "cancel"; @@ -661,7 +746,7 @@ export type AgentChatPermissionMode = "default" | "auto" | "plan" | "edit" | "fu export type AgentChatExecutionMode = "focused" | "parallel" | "subagents" | "teams"; export type AgentChatInteractionMode = "default" | "plan"; export type AgentChatIdentityKey = "cto" | `agent:${string}`; -export type AgentChatSurface = "work" | "automation" | "mission"; +export type AgentChatSurface = "work" | "automation"; export type AgentChatCursorConfigValue = string | boolean | number; export type AgentChatCursorConfigSelectOption = { value: string; @@ -688,7 +773,7 @@ export type AgentChatCursorModeSnapshot = { availableModelIds?: string[]; configOptions?: AgentChatCursorConfigOption[]; }; -export type PendingInputSource = "claude" | "codex" | "cursor" | "droid" | "opencode" | "mission" | "ade"; +export type PendingInputSource = "claude" | "codex" | "cursor" | "droid" | "opencode" | "ade"; export type PendingInputKind = "approval" | "question" | "structured_question" | "permissions" | "plan_approval"; export type PendingInputOption = { diff --git a/apps/desktop/src/shared/types/computerUseArtifacts.ts b/apps/desktop/src/shared/types/computerUseArtifacts.ts index 6caec9314..0146e445b 100644 --- a/apps/desktop/src/shared/types/computerUseArtifacts.ts +++ b/apps/desktop/src/shared/types/computerUseArtifacts.ts @@ -7,10 +7,6 @@ export type ComputerUseArtifactKind = export type ComputerUseArtifactOwnerKind = | "lane" - | "mission" - | "orchestrator_run" - | "orchestrator_step" - | "orchestrator_attempt" | "chat_session" | "automation_run" | "github_pr" diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 2c1c52d95..b821b96b4 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -3,11 +3,9 @@ // --------------------------------------------------------------------------- import type { ProviderMode, ModelId } from "./core"; -import type { AgentChatModelInfo, AgentChatSession } from "./chat"; +import type { AgentChatModelInfo, AgentChatPermissionMode, AgentChatSession } from "./chat"; import type { LaneType } from "./lanes"; -import type { MissionExecutionPolicy, MissionPermissionConfig, MissionProviderPermissions } from "./missions"; -import type { MissionModelConfig, ModelConfig } from "./models"; -import type { SmartBudgetConfig } from "./budget"; +import type { ModelConfig } from "./models"; import type { LinearSyncConfig } from "./linearSync"; import type { LocalProviderFamily } from "../modelRegistry"; @@ -605,7 +603,6 @@ export const LEGACY_GITHUB_PR_TRIGGER_ALIASES: Readonly<Record<string, Automatio export type AutomationActionType = | "create-lane" | "agent-session" - | "launch-mission" | "predict-conflicts" | "run-tests" | "run-command" @@ -651,8 +648,7 @@ export type AutomationToolFamily = | "tests" | "github" | "linear" - | "browser" - | "mission"; + | "browser"; export type AutomationContextSourceType = | "linked-doc" @@ -729,7 +725,7 @@ export type AutomationAction = { /** Optional Codex fast-mode override for agent-session actions inside a built-in chain. */ codexFastMode?: boolean; /** Optional permission override for agent-session actions inside a built-in chain. */ - permissionConfig?: MissionPermissionConfig; + permissionConfig?: AiPermissionSettings; /** Configuration payload for actions of kind `ade-action`. */ adeAction?: RunAdeActionConfig; /** @@ -741,7 +737,7 @@ export type AutomationAction = { sessionTitle?: string; }; -export type AutomationExecutionKind = "agent-session" | "mission" | "built-in"; +export type AutomationExecutionKind = "agent-session" | "built-in"; export type AutomationLaneMode = "create" | "reuse" | "require-on-trigger"; @@ -785,12 +781,6 @@ export type AutomationExecution = { reasoningEffort?: string | null; codexFastMode?: boolean; }; - /** - * Mission-specific launch hints reused by the automation mission launcher. - */ - mission?: { - title?: string | null; - }; /** * Built-in deterministic tasks such as run-command and run-tests. */ @@ -846,8 +836,8 @@ export type AutomationRule = { trigger: AutomationTrigger; execution?: AutomationExecution; executor: AutomationExecutor; - modelConfig?: MissionModelConfig; - permissionConfig?: MissionPermissionConfig; + modelConfig?: ModelConfig; + permissionConfig?: AiPermissionSettings; templateId?: string; prompt?: string; reviewProfile: AutomationReviewProfile; @@ -879,8 +869,8 @@ export type ConfigAutomationRule = { triggers?: AutomationTrigger[]; execution?: AutomationExecution; executor?: AutomationExecutor; - modelConfig?: MissionModelConfig; - permissionConfig?: MissionPermissionConfig; + modelConfig?: ModelConfig; + permissionConfig?: AiPermissionSettings; templateId?: string; prompt?: string; reviewProfile?: AutomationReviewProfile; @@ -919,7 +909,6 @@ export type AiTaskRoutingKey = | "handoff_summary" | "continuity_summary" | "context_compaction" - | "mission_planning" | "initial_context"; export type AiTaskRoutingRule = { @@ -936,7 +925,6 @@ export type AiFeatureKey = | "commit_messages" | "pr_descriptions" | "terminal_summaries" - | "mission_planning" | "orchestrator" | "initial_context"; @@ -1228,11 +1216,22 @@ export type AiInProcessPermissionSettings = { mode?: AiInProcessPermissionMode; }; +export type AiProviderPermissions = { + claude?: AgentChatPermissionMode; + codex?: AgentChatPermissionMode; + cursor?: AgentChatPermissionMode; + droid?: AgentChatPermissionMode; + opencode?: AgentChatPermissionMode; + codexSandbox?: "read-only" | "workspace-write" | "danger-full-access"; + writablePaths?: string[]; + allowedTools?: string[]; +}; + export type AiPermissionSettings = { cli?: AiCliPermissionSettings; inProcess?: AiInProcessPermissionSettings; - /** Per-provider permission config (preferred over cli/inProcess for missions). */ - providers?: MissionProviderPermissions; + /** Per-provider permission config, preferred over legacy cli/inProcess knobs. */ + providers?: AiProviderPermissions; }; export type WorkerSafetyPolicy = { @@ -1284,13 +1283,10 @@ export type AiOrchestratorConfig = { progressiveLoading?: boolean; maxTotalTokenBudget?: number; maxPerStepTokenBudget?: number; - defaultExecutionPolicy?: Partial<MissionExecutionPolicy>; defaultOrchestratorModel?: ModelConfig; - smartBudget?: SmartBudgetConfig; autoResolveInterventions?: boolean; interventionConfidenceThreshold?: number; hooks?: Partial<Record<AiOrchestratorHookEvent, AiOrchestratorHookConfig>>; - maxConcurrentMissions?: number; laneExclusivity?: boolean; }; @@ -1341,7 +1337,7 @@ export type AiConfig = { apiKeys?: Record<string, string>; localProviders?: AiLocalProviderConfigs; workerSafety?: WorkerSafetyPolicy; - /** Per-feature model overrides, e.g. { mission_planning: "claude-sonnet-4-6" } */ + /** Per-feature model overrides, e.g. { pr_descriptions: "claude-sonnet-4-6" } */ featureModelOverrides?: Partial<Record<AiFeatureKey, string>>; /** Per-feature reasoning effort overrides */ featureReasoningOverrides?: Partial<Record<AiFeatureKey, string | null>>; diff --git a/apps/desktop/src/shared/types/conflicts.ts b/apps/desktop/src/shared/types/conflicts.ts index 15de081f3..83eecbb6d 100644 --- a/apps/desktop/src/shared/types/conflicts.ts +++ b/apps/desktop/src/shared/types/conflicts.ts @@ -159,7 +159,7 @@ export type ConflictProposalProvider = "subscription"; export type ExternalConflictResolverProvider = "codex" | "claude"; -export type ConflictResolverOriginSurface = "mission" | "integration" | "rebase" | "queue" | "graph" | "manual"; +export type ConflictResolverOriginSurface = "integration" | "rebase" | "queue" | "graph" | "manual"; export type ConflictResolverPermissionMode = "read_only" | "guarded_edit" | "full_edit"; @@ -204,7 +204,6 @@ export type ConflictExternalResolverRunSummary = { permissionMode: PrAgentPermissionMode | null; command: string[]; originSurface: ConflictResolverOriginSurface; - originMissionId: string | null; originRunId: string | null; originLabel: string | null; resolverContextKey?: string | null; @@ -227,7 +226,6 @@ export type RunExternalConflictResolverArgs = { reasoningEffort?: string | null; permissionMode?: PrAgentPermissionMode | null; originSurface?: ConflictResolverOriginSurface; - originMissionId?: string | null; originRunId?: string | null; originLabel?: string | null; }; @@ -405,7 +403,6 @@ export type PrepareResolverSessionArgs = { reasoningEffort?: string | null; permissionMode?: PrAgentPermissionMode | null; originSurface?: ConflictResolverOriginSurface; - originMissionId?: string | null; originRunId?: string | null; originLabel?: string | null; additionalInstructions?: string | null; diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index 4b8b7ade6..44f333f30 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -18,14 +18,11 @@ export * from "./iosSimulator"; export * from "./appControl"; export * from "./builtInBrowser"; export * from "./macosVm"; -export * from "./missions"; -export * from "./orchestrator"; export * from "./config"; export * from "./automations"; export * from "./review"; export * from "./packs"; export * from "./agents"; -export * from "./budget"; export * from "./usage"; export * from "./projectState"; export * from "./sync"; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 2e2581788..b8c6b1f15 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -17,7 +17,6 @@ import type { TerminalSessionSummary } from "./sessions"; export type LaneType = "primary" | "worktree" | "attached"; export type LaneRuntimePlacement = "local" | "macos-vm"; -export type MissionLaneRole = "mission_root" | "worker" | "integration" | "result"; export type LaneStatus = { dirty: boolean; @@ -54,8 +53,6 @@ export type LaneSummary = { icon: LaneIcon | null; tags: string[]; folder?: string | null; - missionId?: string | null; - laneRole?: MissionLaneRole | null; runtimePlacement?: LaneRuntimePlacement; createdAt: string; archivedAt?: string | null; @@ -147,7 +144,6 @@ export type LaneRuntimeSummary = { export type LaneStateSnapshotSummary = { laneId: string; agentSummary: Record<string, unknown> | null; - missionSummary: Record<string, unknown> | null; updatedAt: string | null; }; @@ -207,8 +203,6 @@ export type CreateChildLaneArgs = { name: string; description?: string; folder?: string; - missionId?: string | null; - laneRole?: MissionLaneRole | null; baseBranchRef?: string; branchName?: string; linearIssue?: LaneLinearIssue | null; diff --git a/apps/desktop/src/shared/types/linearSync.ts b/apps/desktop/src/shared/types/linearSync.ts index 8b40cfc9f..9034fe842 100644 --- a/apps/desktop/src/shared/types/linearSync.ts +++ b/apps/desktop/src/shared/types/linearSync.ts @@ -14,7 +14,6 @@ export type LinearWorkflowWorkerSelector = | { mode: "none" }; export type LinearWorkflowTargetType = - | "mission" | "employee_session" | "worker_run" | "pr_resolution" @@ -33,7 +32,6 @@ export type LinearWorkflowTarget = { workerSelector?: LinearWorkflowWorkerSelector; employeeIdentityKey?: AgentChatIdentityKey; sessionTemplate?: string | null; - missionTemplate?: string | null; executorKind?: "cto" | "employee" | "worker" | (string & {}); runMode?: "autopilot" | "assisted" | "manual" | (string & {}); phaseProfile?: string | null; @@ -330,7 +328,6 @@ export type LinearWorkflowRun = { currentStepIndex: number; currentStepId: string | null; executionLaneId: string | null; - linkedMissionId: string | null; linkedSessionId: string | null; linkedWorkerRunId: string | null; linkedPrId: string | null; @@ -661,7 +658,6 @@ export type LinearSyncQueueItem = { workerId: string | null; workerSlug: string | null; sessionLabel?: string | null; - missionId: string | null; sessionId: string | null; workerRunId: string | null; prId: string | null; diff --git a/apps/desktop/src/shared/types/missions.ts b/apps/desktop/src/shared/types/missions.ts deleted file mode 100644 index 5dcc5309a..000000000 --- a/apps/desktop/src/shared/types/missions.ts +++ /dev/null @@ -1,1079 +0,0 @@ -// --------------------------------------------------------------------------- -// Mission types -// --------------------------------------------------------------------------- - -import type { ModelConfig, MissionModelConfig } from "./models"; -import type { PrStrategy } from "./prs"; -import type { - OrchestratorExecutorKind, - OrchestratorRunStatus, - OrchestratorWorkerStatus, - TeamRuntimeConfig, - RecoveryLoopPolicy, - IntegrationPrPolicy, - MissionCloseoutRequirement, - MissionFinalizationPolicyKind, -} from "./orchestrator"; -import type { AiCliPermissionMode, AiCliSandboxPermissions, AiInProcessPermissionMode } from "./config"; -import type { AgentChatPermissionMode } from "./chat"; -import type { - ComputerUseArtifactKind, - ComputerUseOwnerSnapshot, -} from "./computerUseArtifacts"; - -/** @deprecated Use MissionProviderPermissions instead. Kept for backward compat with stored missions. */ -export type MissionCliPermissionMode = AiCliPermissionMode; -/** @deprecated Use MissionProviderPermissions instead. Kept for backward compat with stored missions. */ -export type MissionCliSandboxPermissions = AiCliSandboxPermissions; -/** @deprecated Use MissionProviderPermissions instead. Kept for backward compat with stored missions. */ -export type MissionInProcessPermissionMode = AiInProcessPermissionMode; - -/** Per-provider permission mode — mirrors chat pane semantics (AgentChatPermissionMode). */ -export type MissionProviderPermissions = { - /** Permission mode for Claude CLI workers */ - claude?: AgentChatPermissionMode; - /** Permission mode for Codex CLI workers */ - codex?: AgentChatPermissionMode; - /** Permission mode for Cursor SDK workers */ - cursor?: AgentChatPermissionMode; - /** Permission mode for Factory Droid CLI workers */ - droid?: AgentChatPermissionMode; - /** Permission mode for OpenCode-backed API/local model workers */ - opencode?: AgentChatPermissionMode; - /** Codex sandbox level (only relevant for codex) */ - codexSandbox?: "read-only" | "workspace-write" | "danger-full-access"; - /** Additional writable paths for CLI workers */ - writablePaths?: string[]; - /** Allowed tools for Claude CLI workers */ - allowedTools?: string[]; -}; - -export type MissionPermissionConfig = { - /** @deprecated Old CLI-class shape. Kept for backward compat with stored missions. */ - cli?: { - mode?: MissionCliPermissionMode; - sandboxPermissions?: MissionCliSandboxPermissions; - writablePaths?: string[]; - allowedTools?: string[]; - }; - /** @deprecated Old in-process shape. Kept for backward compat with stored missions. */ - inProcess?: { - mode?: MissionInProcessPermissionMode; - }; - /** New per-provider permission shape. Takes precedence over cli/inProcess when present. */ - providers?: MissionProviderPermissions; -}; - -export type MissionStatus = - | "queued" - | "planning" - | "in_progress" - | "intervention_required" - | "completed" - | "failed" - | "canceled"; - -export const TERMINAL_MISSION_STATUSES = new Set<MissionStatus>(["completed", "failed", "canceled"]); - -export type MissionPriority = "urgent" | "high" | "normal" | "low"; - -export type MissionExecutionMode = "local" | "relay"; - -export type MissionStepStatus = - | "pending" - | "running" - | "succeeded" - | "failed" - | "skipped" - | "blocked" - | "canceled"; - -export type MissionArtifactType = - | "summary" - | "pr" - | "link" - | "note" - | "patch" - | "plan" - | "test_report" - | "screenshot" - | "browser_verification" - | "browser_trace" - | "video_recording" - | "console_logs"; - -export type MissionInterventionType = - | "approval_required" - | "manual_input" - | "conflict" - | "policy_block" - | "failed_step" - | "orchestrator_escalation" - | "budget_limit_reached" - | "provider_unreachable" - | "unrecoverable_error" - | "phase_approval"; - -export type MissionInterventionStatus = "open" | "resolved" | "dismissed"; -export type MissionInterventionResolutionKind = - | "answer_provided" - | "accept_defaults" - | "skip_question" - | "cancel_run" - | "stale_phase_approval"; - -export const RESOLUTION_KINDS = new Set<MissionInterventionResolutionKind>([ - "answer_provided", - "accept_defaults", - "skip_question", - "cancel_run", - "stale_phase_approval", -]); - -export function isValidResolutionKind(value: string): value is MissionInterventionResolutionKind { - return RESOLUTION_KINDS.has(value as MissionInterventionResolutionKind); -} - -export function resolutionKindLabel(kind: MissionInterventionResolutionKind): string { - switch (kind) { - case "accept_defaults": return "Accept the default assumptions and continue."; - case "skip_question": return "Skip this clarification and continue with best-effort assumptions."; - case "cancel_run": return "Cancel the run."; - case "stale_phase_approval": return "Close stale phase approval without changing phases."; - case "answer_provided": return "Answer provided."; - } -} - -export type MissionPlannerEngine = "auto" | "claude_cli" | "codex_cli"; - -export type PlannerMissionDomain = "backend" | "frontend" | "infra" | "testing" | "docs" | "release" | "mixed"; - -export type PlannerMissionComplexity = "low" | "medium" | "high"; - -export type PlannerMissionStrategy = "sequential" | "parallel-lite" | "parallel-first"; - -export type PlannerTaskType = "analysis" | "code" | "integration" | "test" | "review" | "merge" | "deploy" | "docs" | "milestone"; - -export type PlannerExecutorHint = "opencode" | "manual" | "either"; - -export type PlannerPreferredScope = "lane" | "file" | "session" | "global"; - -export type PlannerContextProfileRequirement = "deterministic" | "deterministic_plus_narrative"; - -export type PlannerClaimLane = "analysis" | "backend" | "frontend" | "integration" | "conflict"; - -export type PlannerJoinPolicy = "all_success" | "any_success" | "quorum"; - -export type PlannerClarifyingQuestion = { - question: string; - context?: string; - defaultAssumption?: string; - impact?: string; -}; - -export type PlannerClarifyingAnswer = { - questionIndex: number; - question: string; - answer: string; - context?: string; - defaultAssumption?: string; - impact?: string; - source: "user" | "default_assumption"; - answeredAt: string; -}; - -// --------------------------------------------------------------------------- -// Coordinator clarification quiz types (ask_user structured questions) -// --------------------------------------------------------------------------- - -export type ClarificationQuestion = { - question: string; - context?: string; - options?: string[]; // optional multiple choice - defaultAssumption?: string; - impact?: string; -}; - -export type ClarificationAnswer = { - questionIndex: number; - answer: string; // free text or selected option - source: "user" | "default_assumption"; - markedConfusing?: boolean; // user flagged as unclear -}; - -export type ClarificationQuiz = { - questions: ClarificationQuestion[]; - answers: ClarificationAnswer[]; - phase?: string; - submittedAt?: string; -}; - -export type PlannerStepPlan = { - stepId: string; - name: string; - description: string; - taskType: PlannerTaskType; - executorHint: PlannerExecutorHint; - preferredScope: PlannerPreferredScope; - requiresContextProfiles: PlannerContextProfileRequirement[]; - dependencies: string[]; - joinPolicy?: PlannerJoinPolicy; - joinQuorum?: number; - artifactHints: string[]; - claimPolicy: { - lanes: PlannerClaimLane[]; - filePatterns?: string[]; - envKeys?: string[]; - exclusive?: boolean; - }; - timeoutMs?: number; - maxAttempts: number; - retryPolicy: { - baseMs: number; - maxMs: number; - multiplier: number; - maxRetries: number; - }; - outputContract: { - expectedSignals: string[]; - handoffTo?: string[]; - completionCriteria: string; - }; -}; - -export type PlannerPlan = { - schemaVersion: "1.0"; - clarifyingQuestions?: PlannerClarifyingQuestion[]; - clarifyingAnswers?: PlannerClarifyingAnswer[]; - missionSummary: { - title: string; - objective: string; - domain: PlannerMissionDomain; - complexity: PlannerMissionComplexity; - strategy: PlannerMissionStrategy; - parallelismCap: number; - parallelismRationale?: string; - }; - assumptions: string[]; - risks: string[]; - steps: PlannerStepPlan[]; - handoffPolicy: { - externalConflictDefault: "intervention" | "auto_internal_retry" | "manual_merge_step"; - }; -}; - -export type MissionPhaseValidationTier = "none" | "self" | "dedicated"; - -export type ValidationEvidenceRequirement = - | "planning_document" - | "research_summary" - | "changed_files_summary" - | "test_report" - | "review_summary" - | "risk_notes" - | "final_outcome_summary" - | "screenshot" - | "browser_verification" - | "video_recording" - | "browser_trace" - | "console_logs"; - -export type ValidationCapabilityFallbackPolicy = "block" | "warn"; - -export type PhaseCardOrderingConstraints = { - mustBeFirst?: boolean; - mustBeLast?: boolean; - mustFollow?: string[]; - mustPrecede?: string[]; - canLoop?: boolean; - loopTarget?: string | null; -}; - -export type PhaseCardBudget = { - maxTokens?: number; - maxTimeMs?: number; - maxSteps?: number; -}; - -export type PhaseCardAskQuestions = { - enabled: boolean; - maxQuestions?: number | null; - /** When true, Planning cannot exit until a planning question has been answered or explicitly skipped. */ - requiredBeforeExit?: boolean; -}; - -export type PhaseCardValidationGate = { - tier: MissionPhaseValidationTier; - required: boolean; - criteria?: string; - evidenceRequirements?: ValidationEvidenceRequirement[]; - capabilityFallback?: ValidationCapabilityFallbackPolicy; -}; - -export type PhaseCard = { - id: string; - phaseKey: string; - name: string; - description: string; - instructions: string; - model: ModelConfig; - budget: PhaseCardBudget; - orderingConstraints: PhaseCardOrderingConstraints; - askQuestions: PhaseCardAskQuestions; - validationGate: PhaseCardValidationGate; - /** When true, transitioning away from this phase requires explicit user approval. */ - requiresApproval?: boolean; - /** Optional capabilities enabled for this phase (e.g., "agent-browser"). */ - capabilities?: string[]; - isBuiltIn: boolean; - isCustom: boolean; - position: number; - createdAt: string; - updatedAt: string; -}; - -export type PhaseProfile = { - id: string; - name: string; - description: string; - phases: PhaseCard[]; - isBuiltIn: boolean; - isDefault: boolean; - createdAt: string; - updatedAt: string; -}; - -export type ListPhaseItemsArgs = { - includeArchived?: boolean; -}; - -export type SavePhaseItemArgs = { - item: PhaseCard; -}; - -export type DeletePhaseItemArgs = { - phaseKey: string; -}; - -export type ExportPhaseItemsArgs = { - phaseKeys?: string[]; -}; - -export type ExportPhaseItemsResult = { - items: PhaseCard[]; - savedPath: string | null; -}; - -export type ImportPhaseItemsArgs = { - filePath: string; -}; - -export type MissionLogChannel = - | "timeline" - | "runtime" - | "chat" - | "outputs" - | "reflections" - | "retrospectives" - | "interventions"; - -export type MissionLogEntry = { - id: string; - missionId: string; - runId: string | null; - channel: MissionLogChannel; - level: "info" | "warning" | "error"; - at: string; - title: string; - message: string; - stepId?: string | null; - stepKey?: string | null; - attemptId?: string | null; - interventionId?: string | null; - threadId?: string | null; - payload?: Record<string, unknown> | null; -}; - -export type GetMissionLogsArgs = { - missionId: string; - runId?: string | null; - channels?: MissionLogChannel[]; - cursor?: string | null; - limit?: number; -}; - -export type GetMissionLogsResult = { - entries: MissionLogEntry[]; - nextCursor: string | null; - total: number; -}; - -export type MissionLogBundleManifest = { - schema: "ade.mission-log-bundle.v1"; - missionId: string; - runId: string | null; - exportedAt: string; - channels: MissionLogChannel[]; - entryCount: number; - files: Array<{ - name: string; - path: string; - bytes: number; - entries: number; - }>; - includeArtifacts: boolean; -}; - -export type ExportMissionLogsArgs = { - missionId: string; - runId?: string | null; - includeArtifacts?: boolean; -}; - -export type ExportMissionLogsResult = { - bundlePath: string; - manifest: MissionLogBundleManifest; -}; - -export type RuntimeAvailabilityState = { - missionId: string; - runId: string | null; - available: boolean; - paused: boolean; - blockedReason: string | null; - canStart: boolean; - canResume: boolean; -}; - -export type MissionPhaseOverride = { - id: string; - missionId: string; - profileId: string | null; - phases: PhaseCard[]; - createdAt: string; - updatedAt: string; -}; - -export type MissionPhaseConfiguration = { - profile: PhaseProfile | null; - override: MissionPhaseOverride | null; - selectedPhases: PhaseCard[]; -}; - -export type MissionDashboardSnapshot = { - active: Array<{ - mission: MissionSummary; - phaseName: string | null; - phaseProgress: { - completed: number; - total: number; - pct: number; - }; - activeWorkers: number; - elapsedMs: number; - estimatedRemainingMs: number | null; - }>; - recent: Array<{ - mission: MissionSummary; - durationMs: number; - costEstimateUsd: number | null; - action: "view" | "rerun" | "retry" | "resume"; - }>; - weekly: { - missions: number; - successRate: number; - avgDurationMs: number; - totalCostUsd: number; - }; -}; - -export type GetFullMissionViewArgs = { - missionId: string; -}; - -export type FullMissionViewResult = { - mission: MissionDetail | null; - runGraph: import("./orchestrator").OrchestratorRunGraph | null; - artifacts: import("./orchestrator").OrchestratorArtifact[]; - checkpoints: import("./orchestrator").OrchestratorWorkerCheckpoint[]; - dashboard: MissionDashboardSnapshot | null; -}; - -export type ListPhaseProfilesArgs = { - includeArchived?: boolean; -}; - -export type SavePhaseProfileArgs = { - profile: { - id?: string; - name: string; - description?: string; - phases: PhaseCard[]; - isDefault?: boolean; - }; -}; - -export type DeletePhaseProfileArgs = { - profileId: string; -}; - -export type ClonePhaseProfileArgs = { - profileId: string; - name?: string; -}; - -export type ExportPhaseProfileArgs = { - profileId: string; -}; - -export type ExportPhaseProfileResult = { - profile: PhaseProfile; - savedPath: string | null; -}; - -export type ImportPhaseProfileArgs = { - filePath: string; - setAsDefault?: boolean; -}; - -/** Metadata stored on mission step rows. Known keys: - * instructions, missionGoal, laneId, etc. */ -export type MissionStepMetadata = Record<string, unknown>; - -export type MissionSummary = { - id: string; - title: string; - prompt: string; - laneId: string | null; - laneName: string | null; - missionLaneId?: string | null; - missionLaneName?: string | null; - resultLaneId?: string | null; - resultLaneName?: string | null; - status: MissionStatus; - priority: MissionPriority; - executionMode: MissionExecutionMode; - targetMachineId: string | null; - outcomeSummary: string | null; - lastError: string | null; - artifactCount: number; - openInterventions: number; - totalSteps: number; - completedSteps: number; - createdAt: string; - updatedAt: string; - startedAt: string | null; - completedAt: string | null; -}; - -export type MissionStep = { - id: string; - missionId: string; - index: number; - title: string; - detail: string | null; - kind: string; - laneId: string | null; - status: MissionStepStatus; - createdAt: string; - updatedAt: string; - startedAt: string | null; - completedAt: string | null; - metadata: MissionStepMetadata | null; -}; - -export type MissionEvent = { - id: string; - missionId: string; - eventType: string; - actor: string; - summary: string; - payload: Record<string, unknown> | null; - createdAt: string; -}; - -export type MissionDetailWarning = { - code: "invalid_json" | "truncated_events"; - source: "mission" | "step" | "event" | "artifact" | "intervention"; - field: string; - recordId: string | null; - message: string; -}; - -export type MissionArtifact = { - id: string; - missionId: string; - artifactType: MissionArtifactType; - title: string; - description: string | null; - uri: string | null; - laneId: string | null; - createdBy: string; - createdAt: string; - updatedAt: string; - metadata: Record<string, unknown> | null; -}; - -export type MissionIntervention = { - id: string; - missionId: string; - interventionType: MissionInterventionType; - status: MissionInterventionStatus; - resolutionKind?: MissionInterventionResolutionKind | null; - title: string; - body: string; - requestedAction: string | null; - resolutionNote: string | null; - laneId: string | null; - createdAt: string; - updatedAt: string; - resolvedAt: string | null; - metadata: Record<string, unknown> | null; -}; - -export type MissionDetail = MissionSummary & { - steps: MissionStep[]; - events: MissionEvent[]; - artifacts: MissionArtifact[]; - interventions: MissionIntervention[]; - warnings?: MissionDetailWarning[]; - plannerPlan?: PlannerPlan | null; - phaseConfiguration?: MissionPhaseConfiguration | null; -}; - -export type MissionAgentRuntimeConfig = { - /** Allow coordinator/planner to use multiple workers in parallel. */ - allowParallelAgents: boolean; - /** Allow workers to spawn nested/sub-agents where supported by provider runtime. */ - allowSubAgents: boolean; - /** Allow Claude-native agent teams (for Claude CLI runtimes). */ - allowClaudeAgentTeams: boolean; -}; - -export type ListMissionsArgs = { - status?: MissionStatus | "active"; - laneId?: string; - limit?: number; - includeArchived?: boolean; -}; - -export type CreateMissionArgs = { - prompt: string; - title?: string; - laneId?: string | null; - priority?: MissionPriority; - executionMode?: MissionExecutionMode; - targetMachineId?: string | null; - plannerEngine?: MissionPlannerEngine; - planningTimeoutMs?: number; - autostart?: boolean; - launchMode?: "autopilot" | "manual"; - autopilotExecutor?: OrchestratorExecutorKind; - executionPolicy?: Partial<MissionExecutionPolicy>; - /** Mission-level recovery settings (replaces executionPolicy fields) */ - recoveryLoop?: RecoveryLoopPolicy; - modelConfig?: MissionModelConfig; - /** Team runtime configuration for agent-team orchestration */ - teamRuntime?: TeamRuntimeConfig; - /** Agent runtime capabilities/preferences passed to planner + coordinator. */ - agentRuntime?: MissionAgentRuntimeConfig; - /** Exact persistent employee owner for employee-routed missions. */ - employeeAgentId?: string | null; - /** Optional phase profile selection for launch */ - phaseProfileId?: string | null; - /** Optional mission-scoped phase override sequence */ - phaseOverride?: PhaseCard[]; - /** Per-provider worker permission overrides for this mission */ - permissionConfig?: MissionPermissionConfig; -}; - -export type MissionPreflightCheckId = - | "models" - | "capabilities" - | "computer_use" - | "permissions" - | "lane_claim" - | "worktrees" - | "knowledge_sync" - | "phase_structural" - | "phase_ordering" - | "phase_semantic" - | "budget"; - -export type MissionPreflightSeverity = "pass" | "warning" | "fail"; - -export type MissionPreflightChecklistItem = { - id: MissionPreflightCheckId; - severity: MissionPreflightSeverity; - title: string; - summary: string; - details: string[]; - fixHint?: string; -}; - -export type MissionPreflightPhaseEstimate = { - phaseKey: string; - phaseName: string; - estimatedTokens: number | null; - estimatedCostUsd: number | null; - estimatedTimeMs: number | null; - configuredMaxTokens: number | null; - configuredMaxTimeMs: number | null; -}; - -export type MissionPreflightBudgetEstimate = { - mode: "subscription" | "api-key"; - estimatedTokens: number | null; - estimatedCostUsd: number | null; - estimatedTimeMs: number | null; - actualSpendUsd?: number | null; - burnRateUsdPerHour?: number | null; - forecast?: import("./budget").MissionBudgetForecast; - perPhase: MissionPreflightPhaseEstimate[]; - note?: string; -}; - -export type MissionPreflightResult = { - canLaunch: boolean; - checkedAt: string; - profileName: string | null; - selectedPhaseCount: number; - hardFailures: number; - warnings: number; - checklist: MissionPreflightChecklistItem[]; - budgetEstimate: MissionPreflightBudgetEstimate | null; - approvalSummary?: MissionPreflightApprovalSummary | null; - computerUse?: { - requiredKinds: ComputerUseArtifactKind[]; - /** Missing proof kinds that are configured as blocking. Warning-only gaps stay in the checklist. */ - missingKinds: ComputerUseArtifactKind[]; - availableExternalBackends: string[]; - blocked: boolean; - summary: string; - } | null; -}; - -export type MissionPreflightRequest = { - launch: CreateMissionArgs; -}; - -export type MissionPreflightApprovalSummary = { - missionGoal: string; - laneId: string | null; - laneLabel: string | null; - recommendedExecution: { - orchestratorModelId: string | null; - strategy: string; - teamRuntimeEnabled: boolean; - teammateCount: number; - }; - phaseLabels: string[]; - validationApproach: string[]; - conflictAssumptions: string[]; - knownBlockers: string[]; -}; - -export type MissionRunViewDisplayStatus = - | "not_started" - | "starting" - | "running" - | "paused" - | "blocked" - | "completed" - | "failed" - | "canceled"; - -export type MissionRunViewSeverity = "info" | "warning" | "error" | "success"; - -export type MissionRunViewHaltReason = { - source: "intervention" | "coordinator" | "run" | "mission"; - title: string; - detail: string; - severity: MissionRunViewSeverity; - interventionId?: string | null; - createdAt?: string | null; -}; - -export type MissionRunViewLatestIntervention = { - id: string; - title: string; - body: string; - interventionType: MissionInterventionType; - status: MissionInterventionStatus; - requestedAction: string | null; - ownerLabel?: string | null; - createdAt: string; -}; - -export type MissionRunViewWorkerSummary = { - attemptId: string | null; - stepId: string | null; - stepKey: string | null; - stepTitle: string | null; - laneId: string | null; - sessionId: string | null; - executorKind: OrchestratorExecutorKind | null; - state: OrchestratorWorkerStatus | "blocked" | "unknown"; - status: "active" | "blocked" | "completed" | "failed" | "idle"; - lastHeartbeatAt: string | null; - completedAt: string | null; -}; - -export type MissionRunViewProgressAudience = "mission_feed" | "timeline" | "thread"; - -export type MissionRunViewProgressItem = { - id: string; - at: string; - kind: "system" | "worker" | "validation" | "intervention" | "user"; - title: string; - detail: string; - severity: MissionRunViewSeverity; - audience?: MissionRunViewProgressAudience; - source?: "timeline" | "runtime" | "intervention" | "mission"; - stepId?: string | null; - stepKey?: string | null; - attemptId?: string | null; -}; - -export type MissionRunView = { - missionId: string; - runId: string | null; - lifecycle: { - missionStatus: MissionStatus; - runStatus: OrchestratorRunStatus | null; - displayStatus: MissionRunViewDisplayStatus; - summary: string; - startedAt: string | null; - completedAt: string | null; - }; - active: { - phaseKey: string | null; - phaseName: string | null; - stepId: string | null; - stepKey: string | null; - stepTitle: string | null; - featureLabel: string | null; - }; - coordinator: { - available: boolean | null; - mode: "offline" | "consult_only" | "continuation_required" | null; - summary: string | null; - detail: string | null; - updatedAt: string | null; - }; - latestIntervention: MissionRunViewLatestIntervention | null; - haltReason: MissionRunViewHaltReason | null; - workers: MissionRunViewWorkerSummary[]; - progressLog: MissionRunViewProgressItem[]; - lastMeaningfulProgress: MissionRunViewProgressItem | null; - closeoutRequirements: MissionCloseoutRequirement[]; - computerUse?: ComputerUseOwnerSnapshot | null; -}; - -export type GetMissionRunViewArgs = { - missionId: string; - runId?: string | null; -}; - -export type UpdateMissionArgs = { - missionId: string; - title?: string; - prompt?: string; - laneId?: string | null; - status?: MissionStatus; - priority?: MissionPriority; - executionMode?: MissionExecutionMode; - targetMachineId?: string | null; - outcomeSummary?: string | null; - lastError?: string | null; -}; - -export type UpdateMissionStepArgs = { - missionId: string; - stepId: string; - status: MissionStepStatus; - note?: string | null; -}; - -export type AddMissionArtifactArgs = { - missionId: string; - artifactType: MissionArtifactType; - title: string; - description?: string | null; - uri?: string | null; - laneId?: string | null; - metadata?: Record<string, unknown> | null; - createdBy?: string; - actor?: string; -}; - -export type AddMissionInterventionArgs = { - missionId: string; - interventionType: MissionInterventionType; - title: string; - body: string; - requestedAction?: string | null; - laneId?: string | null; - metadata?: Record<string, unknown> | null; - pauseMission?: boolean; -}; - -export type ResolveMissionInterventionArgs = { - missionId: string; - interventionId: string; - status: Exclude<MissionInterventionStatus, "open">; - note?: string | null; - resolutionKind?: MissionInterventionResolutionKind | null; -}; - -export type DeleteMissionArgs = { - missionId: string; -}; - -export type ArchiveMissionArgs = { - missionId: string; -}; - -export type MissionConcurrencyCheckResult = { - allowed: boolean; - reason?: string; - queuePosition?: number; -}; - -export type MissionLaneClaimCheckResult = { - claimed: boolean; - byMissionId?: string; - reason?: string; -}; - -export type MissionConcurrencyConfig = { - maxConcurrentMissions: number; - laneExclusivity: boolean; -}; - -export type MissionsEventPayload = { - type: "missions-updated"; - missionId?: string; - reason?: string; - claimToken?: string | null; - at: string; -}; - -export type GetMissionEventsArgs = { - missionId: string; - limit?: number; - before?: string | null; -}; - -export type MissionEventsPage = { - missionId: string; - events: MissionEvent[]; - nextCursor: string | null; - hasMore: boolean; - warnings: MissionDetailWarning[]; -}; - -export type MissionStepHandoff = { - id: string; - missionId: string; - missionStepId: string | null; - runId: string | null; - stepId: string | null; - attemptId: string | null; - handoffType: string; - producer: string; - payload: Record<string, unknown>; - createdAt: string; -}; - -export type PlanningPhaseMode = "off" | "auto" | "manual_review"; -export type TestingPhaseMode = "none" | "post_implementation" | "tdd"; -export type GatePhaseMode = "required" | "optional" | "off"; -export type PhaseModelChoice = string; - -export type MissionLevelSettings = { - recoveryLoop?: RecoveryLoopPolicy; - integrationPr?: IntegrationPrPolicy; - prStrategy?: PrStrategy; - finalizationPolicyKind?: MissionFinalizationPolicyKind; - teamRuntime?: TeamRuntimeConfig; -}; - -export type MissionExecutionPolicy = { - planning: { mode: PlanningPhaseMode; model?: PhaseModelChoice; reasoningEffort?: string }; - implementation: { model?: PhaseModelChoice; reasoningEffort?: string }; - testing: { mode: TestingPhaseMode; model?: PhaseModelChoice; reasoningEffort?: string }; - validation: { mode: GatePhaseMode; model?: PhaseModelChoice; reasoningEffort?: string }; - codeReview: { mode: GatePhaseMode; model?: PhaseModelChoice; reasoningEffort?: string }; - testReview: { mode: GatePhaseMode; model?: PhaseModelChoice; reasoningEffort?: string }; - prReview: { mode: "off" | "auto"; model?: PhaseModelChoice; reasoningEffort?: string }; - merge: { mode: "off" }; - recoveryLoop?: RecoveryLoopPolicy; - integrationPr?: IntegrationPrPolicy; - prStrategy?: PrStrategy; - finalizationPolicyKind?: MissionFinalizationPolicyKind; - /** Team runtime: spawn coordinator + teammates with shared task list and direct messaging */ - teamRuntime?: TeamRuntimeConfig; -}; - -export type UserSteeringDirective = { - missionId: string; - directive: string; - priority: "suggestion" | "instruction" | "override"; - targetStepKey?: string | null; -}; - -export type SteerMissionArgs = UserSteeringDirective & { - interventionId?: string | null; - resolutionKind?: MissionInterventionResolutionKind | null; -}; - -export type SteerMissionResult = { - acknowledged: boolean; - appliedAt: string; - response?: string; -}; - -export type MissionMetricToggle = - | "planning" - | "implementation" - | "testing" - | "validation" - | "code_review" - | "test_review" - | "integration" - | "cost" - | "tokens" - | "retries" - | "claims" - | "context_pressure" - | "interventions"; - -export type MissionMetricsConfig = { - missionId: string; - toggles: MissionMetricToggle[]; - updatedAt: string; -}; - -export type MissionMetricSample = { - id: string; - missionId: string; - runId: string | null; - attemptId: string | null; - metric: MissionMetricToggle | string; - value: number; - unit: string | null; - metadata?: Record<string, unknown> | null; - createdAt: string; -}; - -export type GetMissionMetricsArgs = { - missionId: string; - runId?: string | null; - limit?: number; -}; - -export type SetMissionMetricsConfigArgs = { - missionId: string; - toggles: MissionMetricToggle[]; -}; diff --git a/apps/desktop/src/shared/types/models.ts b/apps/desktop/src/shared/types/models.ts index e0000f0cf..57f44fefc 100644 --- a/apps/desktop/src/shared/types/models.ts +++ b/apps/desktop/src/shared/types/models.ts @@ -14,57 +14,12 @@ export type ModelConfig = { thinkingLevel?: ThinkingLevel; }; -/** The types of AI calls the orchestrator makes internally. - * The coordinator handles all decision-making (evaluation, diagnosis, - * plan adjustment, intervention) through its own reasoning — no separate call types needed. */ -export type OrchestratorCallType = - | "coordinator" - | "chat_response"; - -/** Mission-level timeout cap for orchestrator internal AI decision calls. */ -export type OrchestratorDecisionTimeoutCapHours = 6 | 12 | 24 | 48; - -/** Per-call-type model configuration for orchestrator intelligence */ -export type OrchestratorIntelligenceConfig = { - [K in OrchestratorCallType]?: ModelConfig; -}; - /** Per-provider tier ceiling — users set these based on their plan limits */ export type ProviderBudgetLimits = { fiveHourTokenLimit: number; weeklyTokenLimit: number; }; -/** Named model profile for quick mission configuration */ -export type MissionModelProfile = { - id: string; - name: string; - description: string; - isBuiltIn: boolean; - orchestratorModel: ModelConfig; - decisionTimeoutCapHours?: OrchestratorDecisionTimeoutCapHours; - phaseDefaults: { - planning: ModelConfig; - implementation: ModelConfig; - testing: ModelConfig; - validation: ModelConfig; - codeReview: ModelConfig; - testReview: ModelConfig; - prReview?: ModelConfig; - }; - intelligenceConfig: OrchestratorIntelligenceConfig; - smartBudget?: import("./budget").SmartBudgetConfig; -}; - -/** The full mission model configuration stored in launch metadata */ -export type MissionModelConfig = { - profileId?: string; - orchestratorModel: ModelConfig; - decisionTimeoutCapHours?: OrchestratorDecisionTimeoutCapHours; - intelligenceConfig?: OrchestratorIntelligenceConfig; - smartBudget?: import("./budget").SmartBudgetConfig; -}; - export type ModelCapabilityProfile = { provider: ModelProvider; modelId: string; diff --git a/apps/desktop/src/shared/types/orchestrator.ts b/apps/desktop/src/shared/types/orchestrator.ts deleted file mode 100644 index 3d0e7f2f6..000000000 --- a/apps/desktop/src/shared/types/orchestrator.ts +++ /dev/null @@ -1,1776 +0,0 @@ -// --------------------------------------------------------------------------- -// Orchestrator types -// --------------------------------------------------------------------------- - -import type { AgentChatFileRef } from "./chat"; -import type { ModelId } from "./core"; -import type { PrDepth, QueueWaitReason } from "./prs"; -import type { MissionDetail, MissionStepHandoff, PhaseCard } from "./missions"; -import type { - OrchestratorContextProfileId, - PackDeltaDigestV1, -} from "./packs"; - -// --------------------------------------------------------------------------- -// Metadata type aliases -// --------------------------------------------------------------------------- - -/** Metadata stored on orchestrator run/step/attempt rows. Known keys: - * integrationStage, mergeMode, sourceLaneIds, targetLaneId, prId, etc. */ -export type OrchestratorMetadata = Record<string, unknown>; - -// --------------------------------------------------------------------------- -// Fan-out types — used by the meta-reasoner and orchestrator fan-out logic -// --------------------------------------------------------------------------- - -/** Decision returned by the meta-reasoner for fan-out dispatch. */ -export type FanOutDecision = { - strategy: "inline" | "internal_parallel" | "external_parallel" | "hybrid"; - subtasks: Array<{ - title: string; - instructions: string; - files: string[]; - complexity: "trivial" | "simple" | "moderate" | "complex"; - estimatedTokens?: number; - }>; - reasoning: string; - clusters?: Array<{ - subtaskIndices: number[]; - reason: string; - }>; -}; - -/** Emitted when the coordinator mutates the mission DAG. Streamed to UI for reactive updates. */ -export type DagMutationEvent = { - runId: string; - mutation: - | { type: "step_added"; step: OrchestratorStep } - | { type: "step_skipped"; stepKey: string; reason: string } - | { type: "steps_merged"; sourceKeys: string[]; targetStep: OrchestratorStep } - | { type: "step_split"; sourceKey: string; children: OrchestratorStep[] } - | { type: "dependency_changed"; stepKey: string; newDeps: string[] } - | { type: "status_changed"; stepKey: string; newStatus: string }; - timestamp: string; - source: "coordinator" | "system" | "user"; -}; - -export type OrchestratorRunStatus = - | "queued" - | "bootstrapping" - | "active" - | "paused" - | "completing" - | "succeeded" - | "failed" - | "canceled"; - -/** New task-level status for team-based claiming and tracking */ -export type OrchestratorTaskStatus = - | "pending" - | "ready" - | "claimed" - | "running" - | "blocked" - | "done" - | "canceled"; - -export type OrchestratorStepStatus = - | "pending" - | "ready" - | "running" - | "succeeded" - | "failed" - | "blocked" - | "skipped" - | "superseded" - | "canceled"; - -export type OrchestratorAttemptStatus = - | "queued" - | "running" - | "succeeded" - | "failed" - | "blocked" - | "canceled"; - -export type OrchestratorJoinPolicy = "all_success" | "any_success" | "quorum" | "advisory"; - -// Built-in executor kinds. Third-party adapters can register any string. -export type OrchestratorExecutorKind = - | "claude" - | "codex" - | "cursor" - | "droid" - | "opencode" - | "shell" - | "manual" - | (string & {}); - -export type OrchestratorErrorClass = - | "none" - | "transient" - | "deterministic" - | "policy" - | "claim_conflict" - | "startup_failure" - | "executor_failure" - | "interrupted" - | "configuration_error" - | "canceled" - | "resume_recovered" - | "planner_contract_violation" - | "soft_success_blocking_failure"; - -export type OrchestratorClaimScope = "lane" | "file" | "env" | "task"; - -export type OrchestratorClaimState = "active" | "released" | "expired"; - -export type OrchestratorDocsRef = { - path: string; - sha256: string; - bytes: number; - truncated: boolean; - mode: "digest_ref" | "full_body"; -}; - -export type OrchestratorContextSnapshotCursor = { - lanePackKey: string | null; - lanePackVersionId: string | null; - lanePackVersionNumber: number | null; - projectPackKey: string | null; - projectPackVersionId: string | null; - projectPackVersionNumber: number | null; - packDeltaSince: string | null; - docs: OrchestratorDocsRef[]; - packDeltaDigest?: PackDeltaDigestV1 | null; - missionHandoffIds?: string[]; - missionHandoffDigest?: { - summarizedCount: number; - byType: Record<string, number>; - oldestCreatedAt: string | null; - newestCreatedAt: string | null; - } | null; - contextSources?: string[]; - docsMode?: "digest_ref" | "full_body"; - docsBudgetBytes?: number; - docsConsumedBytes?: number; - docsTruncatedCount?: number; - controlPackV2?: { - budgetBytes: number; - consumedBytes: number; - truncated: boolean; - frontier: { - pending: number; - ready: number; - running: number; - blocked: number; - terminal: number; - }; - openQuestions: number; - activeClaims: number; - activeClaimConflicts: number; - gateState: "pass" | "warn" | "fail" | "unknown"; - recentDecisions: string[]; - laneStatusMap: Array<{ laneId: string | null; stepKey: string; status: OrchestratorStepStatus }>; - }; - executionPackV2?: { - budgetBytes: number; - consumedBytes: number; - truncated: boolean; - stepKey: string; - stepTitle: string; - dependencies: Array<{ stepId: string; status: OrchestratorStepStatus }>; - handoffIds: string[]; - handoffDigest: OrchestratorContextSnapshotCursor["missionHandoffDigest"]; - }; - deepPackV2?: { - budgetBytes: number; - consumedBytes: number; - truncated: boolean; - docsMode: "digest_ref" | "full_body"; - docsCount: number; - fullDocsIncluded: number; - docsRefsOnly: number; - }; -}; - -export type OrchestratorRun = { - id: string; - missionId: string; - projectId: string; - status: OrchestratorRunStatus; - contextProfile: OrchestratorContextProfileId; - schedulerState: string; - createdAt: string; - updatedAt: string; - startedAt: string | null; - completedAt: string | null; - lastError: string | null; - metadata: OrchestratorMetadata | null; - completionDiagnostics?: CompletionDiagnostic[]; -}; - -export type OrchestratorStep = { - id: string; - runId: string; - missionStepId: string | null; - stepKey: string; - stepIndex: number; - title: string; - laneId: string | null; - status: OrchestratorStepStatus; - joinPolicy: OrchestratorJoinPolicy; - quorumCount: number | null; - dependencyStepIds: string[]; - retryLimit: number; - retryCount: number; - lastAttemptId: string | null; - createdAt: string; - updatedAt: string; - startedAt: string | null; - completedAt: string | null; - metadata: OrchestratorMetadata | null; -}; - -export type OrchestratorAttemptResultEnvelope = { - schema: "ade.orchestratorAttempt.v1"; - success: boolean; - summary: string; - outputs: Record<string, unknown> | null; - warnings: string[]; - sessionId: string | null; - trackedSession: boolean; -}; - -export type OrchestratorAttempt = { - id: string; - runId: string; - stepId: string; - attemptNumber: number; - status: OrchestratorAttemptStatus; - executorKind: OrchestratorExecutorKind; - executorSessionId: string | null; - trackedSessionEnforced: boolean; - contextProfile: OrchestratorContextProfileId; - contextSnapshotId: string | null; - errorClass: OrchestratorErrorClass; - errorMessage: string | null; - retryBackoffMs: number; - createdAt: string; - startedAt: string | null; - completedAt: string | null; - resultEnvelope: OrchestratorAttemptResultEnvelope | null; - metadata: OrchestratorMetadata | null; - modelId?: ModelId; -}; - -export type OrchestratorClaim = { - id: string; - runId: string; - stepId: string | null; - attemptId: string | null; - ownerId: string; - scopeKind: OrchestratorClaimScope; - scopeValue: string; - state: OrchestratorClaimState; - acquiredAt: string; - heartbeatAt: string; - expiresAt: string; - releasedAt: string | null; - policy: Record<string, unknown> | null; - metadata: Record<string, unknown> | null; -}; - -// --------------------------------------------------------------------------- -// Team Runtime Types — agent-team-based orchestration -// --------------------------------------------------------------------------- - -export type OrchestratorTeamMemberRole = "coordinator" | "teammate" | "worker"; - -export type OrchestratorTeamMemberStatus = - | "spawning" - | "active" - | "idle" - | "completing" - | "terminated" - | "failed"; - -export type OrchestratorTeamMemberSource = - | "ade-worker" - | "ade-subagent" - | "claude-native"; - -export type OrchestratorTeamMember = { - id: string; - runId: string; - missionId: string; - provider: string; - model: string; - role: OrchestratorTeamMemberRole; - source: OrchestratorTeamMemberSource; - parentWorkerId?: string | null; - sessionId: string | null; - status: OrchestratorTeamMemberStatus; - claimedTaskIds: string[]; - metadata: Record<string, unknown> | null; - createdAt: string; - updatedAt: string; -}; - -export type OrchestratorTeamRuntimePhase = - | "bootstrapping" - | "planning" - | "executing" - | "completing" - | "done" - | "failed"; - -export type OrchestratorTeamRuntimeState = { - runId: string; - phase: OrchestratorTeamRuntimePhase; - completionRequested: boolean; - completionValidated: boolean; - lastValidationError: string | null; - coordinatorSessionId: string | null; - teammateIds: string[]; - createdAt: string; - updatedAt: string; -}; - -/** Team runtime config for mission launch */ -export type TeamRuntimeConfig = { - enabled: boolean; - targetProvider: "claude" | "codex" | "auto"; - teammateCount: number; - /** Coordinator may use parallel workers/teammates when true. */ - allowParallelAgents?: boolean; - /** Coordinator/workers may delegate to nested sub-agents when true. */ - allowSubAgents?: boolean; - /** Enable Claude-native agent teams bridge when running Claude CLI workers. */ - allowClaudeAgentTeams?: boolean; - template?: TeamTemplate; - toolProfiles?: Record<string, RoleToolProfile>; - policyOverrides?: MissionPolicyFlags; -}; - -export type MissionPolicyFlags = { - clarificationMode?: "always" | "auto_if_uncertain" | "off"; - maxClarificationQuestions?: number; - strictTdd?: boolean; - requireValidatorPass?: boolean; - maxParallelWorkers?: number; - riskApprovalMode?: "auto" | "confirm_high_risk" | "confirm_all"; -}; - -export type RoleToolProfile = { - allowedTools: string[]; - blockedTools?: string[]; - notes?: string; -}; - -export type RoleDefinition = { - name: string; - description: string; - capabilities: string[]; - maxInstances?: number; - toolProfile?: RoleToolProfile; -}; - -export type TeamTemplate = { - id: string; - name: string; - roles: RoleDefinition[]; - policyDefaults: MissionPolicyFlags; - constraints: { - maxWorkers: number; - requiredRoles: string[]; - }; -}; - -export type ValidationContract = { - level: "step" | "milestone" | "mission"; - tier: "self" | "dedicated"; - required: boolean; - criteria: string; - evidence: string[]; - maxRetries: number; -}; - -export type ValidationFinding = { - code: string; - severity: "low" | "medium" | "high"; - message: string; - remediation?: string; - references?: string[]; -}; - -export type ValidationResultReport = { - validationId: string; - scope: { - runId: string; - stepId?: string | null; - stepKey?: string | null; - missionId: string; - laneId?: string | null; - }; - contract: ValidationContract; - verdict: "pass" | "fail"; - summary: string; - findings: ValidationFinding[]; - remediationInstructions: string[]; - retriesUsed: number; - createdAt: string; - validatorWorkerId?: string | null; -}; - -export type WorkerStatusReport = { - workerId: string; - stepId?: string | null; - stepKey?: string | null; - runId: string; - missionId: string; - progressPct: number; - blockers: string[]; - confidence: number | null; - nextAction: string; - laneId?: string | null; - details?: string | null; - reportedAt: string; -}; - -export type WorkerPlanResult = { - markdown: string; - summary?: string | null; - title?: string | null; - format?: "markdown"; - artifactPath?: string | null; -}; - -export type WorkerResultReport = { - workerId: string; - stepId?: string | null; - stepKey?: string | null; - runId: string; - missionId: string; - outcome: "succeeded" | "failed" | "partial"; - summary: string; - plan?: WorkerPlanResult | null; - artifacts: Array<{ type: string; title: string; uri?: string | null; metadata?: Record<string, unknown> }>; - filesChanged: string[]; - testsRun: { - command?: string; - passed?: number; - failed?: number; - skipped?: number; - raw?: string | null; - } | null; - laneId?: string | null; - reportedAt: string; -}; - -export type MissionStateProgress = { - currentPhase: string; - completedSteps: number; - totalSteps: number; - activeWorkers: string[]; - blockedSteps: string[]; - failedSteps: string[]; -}; - -export type MissionStateStepOutcome = { - stepKey: string; - stepName: string; - phase: string; - status: "succeeded" | "failed" | "skipped" | "in_progress"; - summary: string; - filesChanged: string[]; - testsRun?: { passed: number; failed: number; skipped: number }; - validation?: { - verdict: "pass" | "fail" | null; - findings: string[]; - }; - warnings: string[]; - completedAt: string | null; -}; - -export type MissionStateStepOutcomePartial = Partial<Omit<MissionStateStepOutcome, "stepKey">> & { - testsRun?: Partial<NonNullable<MissionStateStepOutcome["testsRun"]>>; - validation?: { - verdict?: "pass" | "fail" | null; - findings?: string[]; - }; -}; - -export type MissionStateDecision = { - timestamp: string; - decision: string; - rationale: string; - context: string; -}; - -export type MissionStateIssue = { - id: string; - severity: "low" | "medium" | "high"; - description: string; - affectedSteps: string[]; - status: "open" | "mitigated" | "resolved"; -}; - -export type MissionStatePendingIntervention = { - id: string; - type: string; - title: string; - createdAt: string; -}; - -export type ReflectionSignalType = "wish" | "frustration" | "idea" | "pattern" | "limitation"; - -export type OrchestratorReflectionEntry = { - id: string; - projectId: string; - missionId: string; - runId: string; - stepId: string | null; - attemptId: string | null; - agentRole: string; - phase: string; - signalType: ReflectionSignalType; - observation: string; - recommendation: string; - context: string; - occurredAt: string; - createdAt: string; - schemaVersion: 1; -}; - -export type MissionRetrospective = { - id: string; - missionId: string; - runId: string; - generatedAt: string; - schemaVersion: 1; - finalStatus: OrchestratorRunStatus; - wins: string[]; - failures: string[]; - unresolvedRisks: string[]; - followUpActions: string[]; - topPainPoints: string[]; - topImprovements: string[]; - patternsToCapture: string[]; - estimatedImpact: string; - changelog: Array<{ - previousPainPoint: string; - status: "resolved" | "still_open" | "worsened"; - currentState: string; - fixApplied?: string; - sourceRetrospectiveId?: string; - sourceMissionId?: string; - sourceRunId?: string; - previousPainScore?: number; - currentPainScore?: number; - }>; -}; - -export type OrchestratorRetrospectiveTrend = { - id: string; - projectId: string; - missionId: string; - runId: string; - retrospectiveId: string; - sourceMissionId: string; - sourceRunId: string; - sourceRetrospectiveId: string; - painPointKey: string; - painPointLabel: string; - status: "resolved" | "still_open" | "worsened"; - previousPainScore: number; - currentPainScore: number; - createdAt: string; -}; - -export type OrchestratorRetrospectivePatternStat = { - id: string; - projectId: string; - patternKey: string; - patternLabel: string; - occurrenceCount: number; - firstSeenRetrospectiveId: string; - firstSeenRunId: string; - lastSeenRetrospectiveId: string; - lastSeenRunId: string; - createdAt: string; - updatedAt: string; -}; - -export type MissionCloseoutRequirementKey = - | "planning_document" - | "research_summary" - | "changed_files_summary" - | "test_report" - | "implementation_summary" - | "validation_verdict" - | "screenshot" - | "browser_verification" - | "browser_trace" - | "video_recording" - | "console_logs" - | "risk_notes" - | "pr_url" - | "proposal_url" - | "review_summary" - | "final_outcome_summary"; - -export type MissionCloseoutRequirementStatus = - | "present" - | "missing" - | "incomplete" - | "waived" - | "blocked_by_capability"; - -export type MissionCloseoutRequirement = { - key: MissionCloseoutRequirementKey; - label: string; - required: boolean; - status: MissionCloseoutRequirementStatus; - detail: string | null; - artifactId: string | null; - uri: string | null; - source: "declared" | "discovered" | "runtime" | "waiver"; -}; - -export type MissionFinalizationPolicyKind = "disabled" | "manual" | "integration" | "per-lane" | "queue" | "result_lane"; - -export type MissionFinalizationStatus = - | "idle" - | "finalizing" - | "creating_pr" - | "landing_queue" - | "resolving_integration_conflicts" - | "resolving_queue_conflicts" - | "waiting_for_green" - | "awaiting_operator_review" - | "posting_review_comment" - | "finalization_failed" - | "completed"; - -export type MissionFinalizationPolicy = { - kind: MissionFinalizationPolicyKind; - targetBranch: string | null; - draft: boolean | null; - prDepth: PrDepth | null; - autoRebase: boolean | null; - ciGating: boolean | null; - autoLand: boolean | null; - autoResolveConflicts: boolean | null; - archiveLaneOnLand: boolean | null; - mergeMethod: "merge" | "squash" | "rebase" | null; - conflictResolverModel: string | null; - reasoningEffort: string | null; - description: string | null; -}; - -export type MissionFinalizationState = { - policy: MissionFinalizationPolicy; - status: MissionFinalizationStatus; - executionComplete: boolean; - contractSatisfied: boolean; - blocked: boolean; - blockedReason: string | null; - summary: string | null; - detail: string | null; - resolverJobId: string | null; - integrationLaneId: string | null; - resultLaneId: string | null; - queueGroupId: string | null; - queueId: string | null; - activePrId: string | null; - waitReason: QueueWaitReason | null; - proposalUrl: string | null; - prUrls: string[]; - reviewStatus: string | null; - mergeReadiness: string | null; - requirements: MissionCloseoutRequirement[]; - warnings: string[]; - updatedAt: string; - startedAt: string | null; - completedAt: string | null; -}; - -export type DelegationMode = "exclusive" | "bounded_parallel" | "recovery"; - -export type DelegationIntent = - | "planner" - | "implementation" - | "validation" - | "specialist" - | "subagent" - | "parallel_subtasks" - | "recovery"; - -export type DelegationScope = { - kind: "phase" | "step" | "worker" | "batch"; - key: string; - label?: string | null; -}; - -export type DelegationContractStatus = - | "launching" - | "active" - | "completed" - | "failed" - | "launch_failed" - | "recovering" - | "blocked" - | "canceled"; - -export type DelegationLaunchState = - | "awaiting_context" - | "fetching_context" - | "awaiting_worker_launch" - | "launching_worker" - | "waiting_on_worker" - | "recovering" - | "completed" - | "blocked"; - -export type CoordinatorCapability = - | "observe" - | "fetch_project_context" - | "spawn_top_level_worker" - | "spawn_nested_worker" - | "spawn_parallel_workers" - | "read_repo" - | "message_workers" - | "ask_user" - | "run_control" - | "update_mission_state"; - -export type DelegationFailureCategory = - | "run_context_bug" - | "provider_unreachable" - | "permission_denied" - | "tool_schema_error" - | "native_tool_violation" - | "unknown"; - -export type DelegationRecoveryAction = - | "retry" - | "switch_to_fallback_model" - | "cancel_run"; - -export type DelegationContract = { - schemaVersion: 1; - contractId: string; - runId: string; - ownerKind: "coordinator"; - workerIntent: DelegationIntent; - mode: DelegationMode; - scope: DelegationScope; - phaseKey: string | null; - status: DelegationContractStatus; - launchState: DelegationLaunchState | null; - activeWorkerIds: string[]; - coordinatorCapabilities: CoordinatorCapability[]; - launchPolicy: { - maxLaunchAttempts: number; - }; - failurePolicy: { - retryLimit: number; - escalation: "intervention" | "retry" | "stop"; - }; - batchId?: string | null; - parentContractId?: string | null; - failure?: { - category: DelegationFailureCategory; - reasonCode: string; - retryable: boolean; - recoveryOptions: DelegationRecoveryAction[]; - message: string; - toolName?: string | null; - retryCount: number; - occurredAt: string; - } | null; - metadata?: Record<string, unknown> | null; - createdAt: string; - updatedAt: string; - startedAt?: string | null; - completedAt?: string | null; -}; - -export type MissionCoordinatorAvailability = { - available: boolean; - mode: "offline" | "consult_only" | "continuation_required"; - summary: string; - detail: string | null; - delegation?: { - contractId: string | null; - workerIntent: DelegationIntent | null; - mode: DelegationMode | null; - status: DelegationContractStatus | null; - scopeKey: string | null; - scopeLabel?: string | null; - activeWorkerIds: string[]; - updatedAt: string; - } | null; - updatedAt: string; -}; - -export type MissionStateDocument = { - schemaVersion: 1; - missionId: string; - runId: string; - goal: string; - updatedAt: string; - progress: MissionStateProgress; - stepOutcomes: MissionStateStepOutcome[]; - decisions: MissionStateDecision[]; - activeIssues: MissionStateIssue[]; - modifiedFiles: string[]; - pendingInterventions: MissionStatePendingIntervention[]; - finalization?: MissionFinalizationState | null; - coordinatorAvailability?: MissionCoordinatorAvailability | null; - reflections?: OrchestratorReflectionEntry[]; - latestRetrospective?: MissionRetrospective | null; -}; - -export type MissionStateDocumentPatch = { - addStepOutcome?: MissionStateStepOutcome; - updateStepOutcome?: { - stepKey: string; - updates: MissionStateStepOutcomePartial; - }; - addDecision?: MissionStateDecision; - addIssue?: MissionStateIssue; - resolveIssue?: { id: string; resolution: string }; - updateProgress?: Partial<MissionStateProgress>; - pendingInterventions?: MissionStatePendingIntervention[]; - finalization?: MissionFinalizationState | null; - coordinatorAvailability?: MissionCoordinatorAvailability | null; - reflections?: OrchestratorReflectionEntry[]; - latestRetrospective?: MissionRetrospective | null; -}; - -export type GetMissionStateDocumentArgs = { - runId: string; -}; - -export type ListOrchestratorArtifactsArgs = { - missionId?: string; - runId?: string | null; - stepId?: string | null; -}; - -export type ListOrchestratorWorkerCheckpointsArgs = { - missionId?: string; - runId?: string | null; - stepId?: string | null; -}; - -export type OrchestratorContextSnapshot = { - id: string; - runId: string; - stepId: string | null; - attemptId: string | null; - snapshotType: "run" | "step" | "attempt"; - contextProfile: OrchestratorContextProfileId; - cursor: OrchestratorContextSnapshotCursor; - createdAt: string; -}; - -export type OrchestratorTimelineEvent = { - id: string; - runId: string; - stepId: string | null; - attemptId: string | null; - claimId: string | null; - eventType: string; - reason: string; - detail: Record<string, unknown> | null; - createdAt: string; -}; - -export type OrchestratorRuntimeEventType = - | "progress" - | "heartbeat" - | "question" - | "blocked" - | "done" - | "retry_scheduled" - | "retry_exhausted" - | "planning_artifact_missing" - | "claim_conflict" - | "session_ended" - | "intervention_opened" - | "intervention_resolved" - | "coordinator_steering" - | "coordinator_broadcast" - | "coordinator_skip" - | "coordinator_add_step" - | "coordinator_pause" - | "coordinator_parallelize" - | "coordinator_consolidate" - | "coordinator_shutdown" - | "step_dependencies_updated" - | "step_metadata_updated" - | "fan_out_dispatched" - | "fan_out_complete" - | "worker_status_report" - | "worker_result_report" - | "worker_message" - | "plan_revised" - | "lane_transfer" - | "validation_report" - | "validation_contract_unfulfilled" - | "validation_self_check_reminder" - | "validation_auto_spawned" - | "validation_gate_blocked" - | "reflection_added" - | "retrospective_generated" - | "tool_profiles_updated" - | "budget_exceeded" - | "finalization_queue_landed"; - -export type OrchestratorRuntimeQuestionLink = { - threadId: string; - messageId: string; - replyTo: string | null; -}; - -export type OrchestratorRuntimeBusEvent = { - id: string; - runId: string; - stepId: string | null; - attemptId: string | null; - sessionId: string | null; - eventType: OrchestratorRuntimeEventType; - eventKey: string; - occurredAt: string; - payload: Record<string, unknown> | null; - questionLink?: OrchestratorRuntimeQuestionLink | null; - createdAt: string; -}; - -export type OrchestratorRuntimeEvent = { - type: - | "orchestrator-run-updated" - | "orchestrator-step-updated" - | "orchestrator-attempt-updated" - | "orchestrator-claim-updated"; - runId?: string; - stepId?: string; - attemptId?: string; - claimId?: string; - at: string; - reason: string; -}; - -export type OrchestratorRunGraph = { - run: OrchestratorRun; - steps: OrchestratorStep[]; - attempts: OrchestratorAttempt[]; - claims: OrchestratorClaim[]; - contextSnapshots: OrchestratorContextSnapshot[]; - handoffs: MissionStepHandoff[]; - timeline: OrchestratorTimelineEvent[]; - runtimeEvents?: OrchestratorRuntimeBusEvent[]; - completionEvaluation?: RunCompletionEvaluation; -}; - -export type OrchestratorGateStatus = "pass" | "warn" | "fail"; - -export type OrchestratorGateEntry = { - key: - | "session_delta_checkpoint_pack_latency" - | "pack_freshness_by_type" - | "context_completeness_rate" - | "blocked_run_rate_insufficient_context"; - label: string; - status: OrchestratorGateStatus; - measuredValue: number; - threshold: number; - comparator: "<=" | ">="; - samples: number; - reasons: string[]; - metadata?: Record<string, unknown> | null; -}; - -export type OrchestratorGateReport = { - id: string; - generatedAt: string; - generatedBy: "deterministic_kernel"; - overallStatus: OrchestratorGateStatus; - gates: OrchestratorGateEntry[]; - notes: string[]; -}; - -export type StartOrchestratorRunStepPolicy = { - includeNarrative?: boolean; - includeFullDocs?: boolean; - docsMaxBytes?: number; - claimScopes?: Array<{ - scopeKind: OrchestratorClaimScope; - scopeValue: string; - ttlMs?: number; - }>; -}; - -export type StartOrchestratorRunStepInput = { - missionStepId?: string | null; - stepKey: string; - title: string; - stepIndex: number; - laneId?: string | null; - dependencyStepKeys?: string[]; - joinPolicy?: OrchestratorJoinPolicy; - quorumCount?: number; - retryLimit?: number; - executorKind?: OrchestratorExecutorKind; - metadata?: Record<string, unknown> | null; - policy?: StartOrchestratorRunStepPolicy; -}; - -export type StartOrchestratorRunArgs = { - missionId: string; - runId?: string; - contextProfile?: OrchestratorContextProfileId; - schedulerState?: string; - metadata?: Record<string, unknown> | null; - steps: StartOrchestratorRunStepInput[]; -}; - -export type ListOrchestratorRunsArgs = { - status?: OrchestratorRunStatus; - missionId?: string; - limit?: number; -}; - -export type GetOrchestratorRunGraphArgs = { - runId: string; - timelineLimit?: number; -}; - -export type StartOrchestratorRunFromMissionArgs = { - missionId: string; - runId?: string; - contextProfile?: OrchestratorContextProfileId; - schedulerState?: string; - metadata?: Record<string, unknown> | null; - runMode?: "autopilot" | "manual"; - autopilotOwnerId?: string; - defaultExecutorKind?: OrchestratorExecutorKind; - defaultRetryLimit?: number; - plannerProvider?: "claude" | "codex" | null; -}; - -export type StartOrchestratorAttemptArgs = { - runId: string; - stepId: string; - ownerId: string; - executorKind?: OrchestratorExecutorKind; -}; - -export type CompleteOrchestratorAttemptArgs = { - attemptId: string; - status: Extract<OrchestratorAttemptStatus, "succeeded" | "failed" | "blocked" | "canceled">; - result?: OrchestratorAttemptResultEnvelope; - errorClass?: OrchestratorErrorClass; - errorMessage?: string | null; - retryBackoffMs?: number; - metadata?: Record<string, unknown> | null; -}; - -export type TickOrchestratorRunArgs = { - runId: string; -}; - -export type PauseOrchestratorRunArgs = { - runId: string; - reason?: string; -}; - -export type ResumeOrchestratorRunArgs = { - runId: string; -}; - -export type CancelOrchestratorRunArgs = { - runId: string; - reason?: string; -}; - -export type CleanupOrchestratorTeamResourcesArgs = { - missionId: string; - runId?: string; - cleanupLanes?: boolean; -}; - -export type CleanupOrchestratorTeamResourcesResult = { - missionId: string; - runId: string | null; - laneIds: string[]; - lanesArchived: string[]; - lanesSkipped: string[]; - laneErrors: Array<{ - laneId: string; - error: string; - }>; -}; - -export type HeartbeatOrchestratorClaimsArgs = { - attemptId: string; - ownerId: string; -}; - -export type ListOrchestratorTimelineArgs = { - runId: string; - limit?: number; -}; - -export type GetOrchestratorGateReportArgs = { - refresh?: boolean; -}; - -// ───────────────────────────────────────────────────── -// Worker Lifecycle & Budget -// ───────────────────────────────────────────────────── - -export type OrchestratorWorkerStatus = - | "spawned" - | "initializing" - | "working" - | "waiting_input" - | "idle" - | "completed" - | "failed" - | "disposed"; - -export type OrchestratorWorkerState = { - attemptId: string; - stepId: string; - runId: string; - sessionId: string | null; - executorKind: OrchestratorExecutorKind; - state: OrchestratorWorkerStatus; - lastHeartbeatAt: string; - spawnedAt: string; - completedAt: string | null; - outcomeTags: string[]; -}; - -export type GetOrchestratorWorkerStatesArgs = { - runId: string; -}; - -export type GetTeamMembersArgs = { - runId: string; -}; - -export type GetTeamRuntimeStateArgs = { - runId: string; -}; - -export type FinalizeRunArgs = { - runId: string; - force?: boolean; -}; - -export type FinalizeRunResult = { - finalized: boolean; - blockers: string[]; - finalStatus: OrchestratorRunStatus; -}; - -export type OrchestratorPlannerProvider = "claude" | "codex" | "deterministic" | (string & {}); - -export type StartMissionRunWithAIArgs = { - missionId: string; - runMode?: "autopilot" | "manual"; - autopilotOwnerId?: string; - defaultExecutorKind?: OrchestratorExecutorKind; - defaultRetryLimit?: number; - metadata?: Record<string, unknown> | null; - plannerProvider?: OrchestratorPlannerProvider; - defaultModelId?: ModelId; - /** Team runtime configuration */ - teamRuntime?: TeamRuntimeConfig; -}; - -export type StartMissionRunWithAIResult = { - started: { run: OrchestratorRun; steps: OrchestratorStep[] } | null; - mission: MissionDetail | null; -}; - -// ───────────────────────────────────────────────────── -// Completion Diagnostics -// ───────────────────────────────────────────────────── - -export type CompletionDiagnostic = { - phase: string; - code: - | "phase_required_missing" - | "phase_skipped_by_policy" - | "phase_in_progress" - | "phase_failed" - | "phase_completed_without_success" - | "phase_terminal_without_success" - | "phase_succeeded" - | "required_validation_missing"; - message: string; - blocking: boolean; - details?: Record<string, unknown>; -}; - -export type RunCompletionBlocker = { - code: "running_attempts" | "claimed_tasks" | "unresolved_interventions" | "completion_not_requested" | "validation_failed"; - message: string; - detail?: Record<string, unknown>; -}; - -export type RunCompletionValidation = { - canComplete: boolean; - blockers: RunCompletionBlocker[]; - validatedAt: string; -}; - -export type RunCompletionEvaluation = { - status: OrchestratorRunStatus; - diagnostics: CompletionDiagnostic[]; - riskFactors: string[]; - completionReady: boolean; - validation?: RunCompletionValidation; -}; - -// ───────────────────────────────────────────────────── -// Role Isolation, Team Synthesis, Recovery Loops, -// Context Brokerage, Execution Plan Preview -// ───────────────────────────────────────────────────── - -export type OrchestratorWorkerRole = - | "planning" - | "implementation" - | "testing" - | "code_review" - | "test_review" - | "integration"; - -export type RoleIsolationRule = { - /** Roles that cannot coexist in the same worker session */ - mutuallyExclusive: [OrchestratorWorkerRole, OrchestratorWorkerRole]; - /** Whether to auto-correct (split) or hard-reject invalid plans */ - enforcement: "auto_correct" | "reject"; - /** Human-readable reason for the isolation rule */ - reason: string; -}; - -export type RoleIsolationValidation = { - valid: boolean; - violations: Array<{ - rule: RoleIsolationRule; - affectedStepIds: string[]; - correctionApplied: boolean; - correctionDetail?: string; - }>; - correctedPlan?: boolean; -}; - -export type TeamManifest = { - runId: string; - missionId: string; - synthesizedAt: string; - rationale: string; - complexity: TeamComplexityAssessment; - workers: Array<{ - workerId: string; - role: OrchestratorWorkerRole; - assignedStepKeys: string[]; - laneId: string | null; - executorKind: OrchestratorExecutorKind; - model?: string; - }>; - parallelismCap: number; - parallelLanes: string[][]; - decisionLog: Array<{ - timestamp: string; - decision: string; - reason: string; - source: "policy" | "complexity" | "prompt" | "dag_shape" | "override"; - }>; -}; - -export type TeamComplexityAssessment = { - domain: "frontend" | "backend" | "fullstack" | "infra" | "mixed"; - estimatedScope: "small" | "medium" | "large" | "very_large"; - parallelizable: boolean; - requiresIntegration: boolean; - fileZoneCount: number; - thoroughnessRequested: boolean; -}; - -export type RecoveryLoopPolicy = { - enabled: boolean; - maxIterations: number; - onExhaustion: "fail" | "intervention" | "complete_with_risk"; - minConfidenceDelta?: number; - escalateAfterStagnant?: number; -}; - -export type RecoveryLoopIteration = { - iteration: number; - triggerStepId: string; - triggerPhase: string; - failureReason: string; - fixStepId: string | null; - reReviewStepId: string | null; - reTestStepId: string | null; - outcome: "fixed" | "still_failing" | "escalated" | "max_iterations"; - confidence?: number; - startedAt: string; - completedAt: string | null; - diagnosis?: RecoveryDiagnosis | null; -}; - -export type RecoveryDiagnosisTier = "transient" | "semantic" | "blocker"; - -export type RecoveryDiagnosis = { - tier: RecoveryDiagnosisTier; - classification: string; - adjustedHint: string | null; - peerNotification: string | null; - suggestedModel: string | null; - diagnosedAt: string; -}; - -export type RecoveryLoopState = { - runId: string; - iterations: RecoveryLoopIteration[]; - currentIteration: number; - exhausted: boolean; - stopReason: string | null; -}; - -export type OrchestratorContextView = "implementation" | "review" | "test_review"; - -export type ContextViewPolicy = { - view: OrchestratorContextView; - readOnly: boolean; - includeScratchContext: boolean; - includeArtifacts: boolean; - includeCheckResults: boolean; - includeHandoffSummaries: boolean; - diffMode: "full" | "summary" | "none"; -}; - -export type IntegrationPrPolicy = { - enabled: boolean; - createIntegrationLane: boolean; - prDepth: PrDepth; - draft: boolean; - baseBranch?: string; - conflictResolverModel?: string; -}; - -export type ExecutionPlanPreview = { - runId: string; - missionId: string; - generatedAt: string; - strategy: string; - phases: ExecutionPlanPhase[]; - teamSummary: { - workerCount: number; - parallelLanes: number; - roles: OrchestratorWorkerRole[]; - }; - recoveryPolicy: RecoveryLoopPolicy; - integrationPrPlan: IntegrationPrPolicy; - aligned: boolean; - driftNotes: string[]; -}; - -export type ExecutionPlanPhase = { - phase: string; - enabled: boolean; - stepCount: number; - steps: ExecutionPlanStepPreview[]; - model: string; - executorKind: OrchestratorExecutorKind; - gatePolicy: string; - recoveryEnabled: boolean; -}; - -export type ExecutionPlanStepPreview = { - stepKey: string; - title: string; - role: OrchestratorWorkerRole; - executorKind: OrchestratorExecutorKind; - model: string; - laneId: string | null; - dependencies: string[]; - gateType: string | null; - recoveryOnFailure: boolean; -}; - -// ───────────────────────────────────────────────────── -// Orchestrator Chat / Messaging -// ───────────────────────────────────────────────────── - -export type OrchestratorChatMessage = { - id: string; - missionId: string; - role: "user" | "orchestrator" | "worker" | "agent"; - content: string; - timestamp: string; - stepKey?: string | null; - threadId?: string | null; - target?: OrchestratorChatTarget | null; - visibility?: OrchestratorChatVisibilityMode; - deliveryState?: OrchestratorChatDeliveryState; - sourceSessionId?: string | null; - attemptId?: string | null; - laneId?: string | null; - runId?: string | null; - metadata?: Record<string, unknown> | null; -}; - -export type OrchestratorMissionThreadUserMessageStructuredStream = { - kind: "user_message"; - text: string; - displayText?: string; - internalKind?: "coordinator_runtime_prompt"; - attachments?: AgentChatFileRef[]; -}; - -export type OrchestratorChatThreadType = "coordinator" | "teammate" | "worker"; - -export type OrchestratorChatVisibilityMode = "full" | "digest_only" | "metadata_only"; - -export type OrchestratorChatDeliveryState = "queued" | "delivered" | "failed"; - -export type OrchestratorChatTarget = - | { - kind: "coordinator"; - runId?: string | null; - sessionId?: string | null; - laneId?: string | null; - } - | { - kind: "teammate"; - runId?: string | null; - teamMemberId?: string | null; - sessionId?: string | null; - } - | { - kind: "workers"; - runId?: string | null; - laneId?: string | null; - includeClosed?: boolean; - } - | { - kind: "worker"; - runId?: string | null; - stepId?: string | null; - stepKey?: string | null; - attemptId?: string | null; - sessionId?: string | null; - laneId?: string | null; - } - | { - kind: "agent"; - sourceAttemptId: string; - targetAttemptId: string; - runId?: string | null; - laneId?: string | null; - }; - -export type OrchestratorChatThread = { - id: string; - missionId: string; - threadType: OrchestratorChatThreadType; - title: string; - runId?: string | null; - stepId?: string | null; - stepKey?: string | null; - attemptId?: string | null; - sessionId?: string | null; - laneId?: string | null; - status: "active" | "closed"; - unreadCount: number; - createdAt: string; - updatedAt: string; - metadata?: Record<string, unknown> | null; -}; - -export type OrchestratorThreadEventType = - | "thread_updated" - | "message_appended" - | "message_updated" - | "metrics_updated" - | "worker_digest_updated" - | "worker_replay"; - -export type OrchestratorThreadEvent = { - type: OrchestratorThreadEventType; - missionId: string; - at: string; - threadId?: string | null; - messageId?: string | null; - runId?: string | null; - reason?: string | null; - metadata?: Record<string, unknown> | null; -}; - -export type ListOrchestratorChatThreadsArgs = { - missionId: string; - includeClosed?: boolean; -}; - -export type GetOrchestratorThreadMessagesArgs = { - missionId: string; - threadId: string; - limit?: number; - before?: string | null; -}; - -export type SendOrchestratorThreadMessageArgs = { - missionId: string; - content: string; - attachments?: AgentChatFileRef[]; - visibilityMode?: OrchestratorChatVisibilityMode; - metadata?: Record<string, unknown> | null; -} & ( - | { - threadId: string; - target?: OrchestratorChatTarget | null; - } - | { - threadId?: string | null; - target: OrchestratorChatTarget; - } -); - -export type SendAgentMessageArgs = { - missionId: string; - fromAttemptId: string; - toAttemptId: string; - content: string; - metadata?: Record<string, unknown> | null; -}; - -export type SendOrchestratorChatArgs = { - missionId: string; - content: string; - threadId?: string | null; - target?: OrchestratorChatTarget; - visibilityMode?: OrchestratorChatVisibilityMode; - metadata?: Record<string, unknown> | null; -}; - -export type GetOrchestratorChatArgs = { - missionId: string; -}; - -export type OrchestratorWorkerDigest = { - id: string; - missionId: string; - runId: string; - stepId: string; - stepKey: string | null; - attemptId: string; - laneId: string | null; - sessionId: string | null; - status: "succeeded" | "failed" | "blocked" | "running" | "queued"; - summary: string; - filesChanged: string[]; - testsRun: { - passed: number; - failed: number; - skipped: number; - summary?: string | null; - }; - warnings: string[]; - tokens?: { - input?: number; - output?: number; - total?: number; - } | null; - costUsd?: number | null; - suggestedNextActions?: string[]; - createdAt: string; -}; - -export type ListOrchestratorWorkerDigestsArgs = { - missionId: string; - runId?: string | null; - stepId?: string | null; - attemptId?: string | null; - laneId?: string | null; - limit?: number; -}; - -export type GetOrchestratorWorkerDigestArgs = { - missionId: string; - digestId: string; -}; - -export type OrchestratorContextCheckpoint = { - id: string; - missionId: string; - runId: string | null; - trigger: "step_threshold" | "pressure_soft" | "pressure_hard" | "status_request" | "manual"; - summary: string; - source: { - digestCount: number; - chatMessageCount: number; - compressedMessageCount: number; - }; - createdAt: string; -}; - -export type CoordinatorCheckpoint = { - version: number; - runId: string; - missionId: string; - conversationSummary: string; - lastEventTimestamp: string | null; - turnCount: number; - compactionCount: number; - savedAt: string; -}; - -export type GetOrchestratorContextCheckpointArgs = { - missionId: string; - checkpointId?: string; -}; - -/** A worker recovery checkpoint persisted to DB from `.ade-checkpoint-{stepKey}.md` files. */ -export type OrchestratorWorkerCheckpoint = { - id: string; - missionId: string; - runId: string; - stepId: string; - attemptId: string; - stepKey: string; - content: string; - filePath: string; - createdAt: string; - updatedAt: string; -}; - -export type OrchestratorPromptLayerSource = - | "system_owned" - | "mission_goal" - | "phase_snapshot" - | "step_runtime" - | "runtime_overlay" - | "user_steering" - | "runtime_context"; - -export type OrchestratorPromptSourceKind = - | "default_template" - | "mission_override" - | "run_snapshot" - | "live_effective_prompt" - | "system_owned"; - -export type OrchestratorPromptLayer = { - id: string; - label: string; - source: OrchestratorPromptLayerSource; - sourceKind: OrchestratorPromptSourceKind; - editable: boolean; - text: string; - description?: string | null; -}; - -export type GetOrchestratorPromptInspectorArgs = { - runId: string; - target: "coordinator" | "worker"; - stepId?: string | null; -}; - -export type GetPlanningPromptPreviewArgs = { - missionPrompt: string; - phase: PhaseCard; - phases: PhaseCard[]; - missionId?: string | null; - runId?: string | null; -}; - -export type OrchestratorPromptInspector = { - target: "coordinator" | "worker"; - runId: string; - missionId: string; - stepId: string | null; - phaseKey: string | null; - phaseName: string | null; - title: string; - notes: string[]; - layers: OrchestratorPromptLayer[]; - fullPrompt: string; -}; - -export type OrchestratorArtifactKind = "file" | "branch" | "pr" | "test_report" | "checkpoint" | "custom" | "screenshot" | "video"; - -export type OrchestratorArtifact = { - id: string; - missionId: string; - runId: string; - stepId: string; - attemptId: string; - artifactKey: string; - kind: OrchestratorArtifactKind; - value: string; - metadata: Record<string, unknown>; - declared: boolean; - createdAt: string; -}; - -export type OrchestratorLaneDecision = { - id: string; - missionId: string; - runId: string | null; - stepId: string | null; - stepKey: string | null; - laneId: string | null; - decisionType: "proposal" | "validated" | "override" | "replan"; - validatorOutcome: "pass" | "fail" | "warn"; - ruleHits: string[]; - rationale: string; - metadata?: Record<string, unknown> | null; - createdAt: string; -}; - -export type ListOrchestratorLaneDecisionsArgs = { - missionId: string; - runId?: string | null; - stepId?: string | null; - limit?: number; -}; - -// ── Inter-agent messaging types ── - -export type GetGlobalChatArgs = { - missionId: string; - since?: string | null; - limit?: number; -}; - -export type DeliverMessageArgs = { - missionId: string; - targetAttemptId: string; - content: string; - priority?: "normal" | "urgent"; - fromAttemptId?: string | null; -}; - -export type GetActiveAgentsArgs = { - missionId: string; -}; - -export type ActiveAgentInfo = { - attemptId: string; - stepId: string; - stepKey: string | null; - runId: string; - sessionId: string | null; - state: OrchestratorWorkerStatus; - executorKind: OrchestratorExecutorKind; -}; diff --git a/apps/desktop/src/shared/types/packs.ts b/apps/desktop/src/shared/types/packs.ts index ef6615f92..2f4fde0c9 100644 --- a/apps/desktop/src/shared/types/packs.ts +++ b/apps/desktop/src/shared/types/packs.ts @@ -7,7 +7,7 @@ import type { LaneType, LaneStatus } from "./lanes"; import type { ConflictStatusValue, ConflictRiskLevel } from "./conflicts"; import type { GitConflictState } from "./git"; -export type PackType = "project" | "lane" | "feature" | "conflict" | "plan" | "mission"; +export type PackType = "project" | "lane" | "feature" | "conflict" | "plan"; export type ContextExportLevel = "lite" | "standard" | "deep"; @@ -248,15 +248,6 @@ export type GetProjectExportArgs = { level: ContextExportLevel }; export type GetConflictExportArgs = { laneId: string; peerLaneId?: string | null; level: ContextExportLevel }; export type GetFeatureExportArgs = { featureKey: string; level: ContextExportLevel }; export type GetPlanExportArgs = { laneId: string; level: ContextExportLevel }; -export type GetMissionExportArgs = { missionId: string; level: ContextExportLevel }; - -export type GetMissionPackArgs = { missionId: string }; - -export type RefreshMissionPackArgs = { - missionId: string; - reason: string; - runId?: string | null; -}; export type OrchestratorContextPolicyProfile = { id: string; diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index ab95963af..0dd774ada 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -879,7 +879,6 @@ export type QueueAutomationConfig = { pipeline: PipelineSettings; /** Origin surface (telemetry/attribution) for the auto-resolver agent. */ originSurface: ConflictResolverOriginSurface; - originMissionId: string | null; originRunId: string | null; originLabel: string | null; /** @deprecated Mirrors `pipeline.conflictStrategy === "auto"`. */ @@ -903,7 +902,6 @@ export type StartQueueAutomationArgs = { ciGating?: boolean; pipeline?: PipelineSettings; originSurface?: ConflictResolverOriginSurface; - originMissionId?: string | null; originRunId?: string | null; originLabel?: string | null; /** @deprecated Use `pipeline.conflictStrategy = "auto"` instead. */ @@ -927,7 +925,6 @@ export type ResumeQueueAutomationArgs = { ciGating?: boolean; pipeline?: PipelineSettings; originSurface?: ConflictResolverOriginSurface; - originMissionId?: string | null; originRunId?: string | null; originLabel?: string | null; /** @deprecated Use `pipeline.conflictStrategy = "auto"` instead. */ diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts index 8674e6271..1e9ca9a33 100644 --- a/apps/desktop/src/shared/types/remoteRuntime.ts +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -120,7 +120,6 @@ export type RemoteRuntimeEventCategory = | "orchestrator" | "dag_mutation" | "runtime" - | "mission" | "pty"; export type RemoteRuntimeBufferedEvent = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index b13adfa38..161850312 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -120,7 +120,6 @@ export type SyncClientStatus = { }; export type SyncTransferBlockerKind = - | "mission_run" | "chat_runtime" | "terminal_session" | "managed_process"; @@ -140,8 +139,8 @@ export type SyncTransferReadiness = { export type SyncGetStatusArgs = { /** - * Transfer readiness scans active missions, chats, terminal sessions, and - * managed run processes. Top-level chrome can skip it when it only needs the + * Transfer readiness scans active chats, terminal sessions, and managed run + * processes. Top-level chrome can skip it when it only needs the * connection label. */ includeTransferReadiness?: boolean; @@ -859,7 +858,6 @@ export type NotificationPreferences = { cto: { subagentStarted: boolean; subagentFinished: boolean; - missionPhaseChanged: boolean; }; prs: { ciFailing: boolean; @@ -1017,7 +1015,6 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { cto: { subagentStarted: false, subagentFinished: true, - missionPhaseChanged: true, }, prs: { ciFailing: true, diff --git a/apps/desktop/src/shared/types/usage.ts b/apps/desktop/src/shared/types/usage.ts index 97a9233bf..d740e7b1a 100644 --- a/apps/desktop/src/shared/types/usage.ts +++ b/apps/desktop/src/shared/types/usage.ts @@ -33,13 +33,6 @@ export type UsageActiveSession = { elapsedMs: number; }; -export type UsageMissionBreakdown = { - missionId: string; - missionTitle: string; - totalTokens: number; - costEstimateUsd: number; -}; - export type AggregatedUsageStats = { summary: { totalSessions: number; @@ -52,13 +45,11 @@ export type AggregatedUsageStats = { byModel: UsageModelBreakdown[]; recentSessions: UsageRecentSession[]; activeSessions: UsageActiveSession[]; - missionBreakdown: UsageMissionBreakdown[]; }; export type GetAggregatedUsageArgs = { since?: string | null; limit?: number; - missionId?: string | null; }; // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/shared/workerRuntimeNoise.ts b/apps/desktop/src/shared/workerRuntimeNoise.ts index b5f9f5adb..58a67a286 100644 --- a/apps/desktop/src/shared/workerRuntimeNoise.ts +++ b/apps/desktop/src/shared/workerRuntimeNoise.ts @@ -12,8 +12,7 @@ export function isWorkerBootstrapNoiseLine(line: string): boolean { const lower = normalized.toLowerCase(); return ( - lower.startsWith("ade_mission_id=") - || lower.startsWith("-p \"$(cat ") + lower.startsWith("-p \"$(cat ") || WORKER_PROMPT_PATH_PATTERN.test(normalized) || lower.includes("exec claude --model") || /\bexec codex\b/i.test(normalized) diff --git a/apps/ios/ADE/App/NotificationCategories.swift b/apps/ios/ADE/App/NotificationCategories.swift index 54db10b77..c44f0f91d 100644 --- a/apps/ios/ADE/App/NotificationCategories.swift +++ b/apps/ios/ADE/App/NotificationCategories.swift @@ -20,7 +20,6 @@ enum NotificationCategories { static let prMergeReady = "PR_MERGE_READY" static let ctoSubagentStarted = "CTO_SUBAGENT_STARTED" static let ctoSubagentFinished = "CTO_SUBAGENT_FINISHED" - static let ctoMissionPhase = "CTO_MISSION_PHASE" static let systemAlert = "SYSTEM_ALERT" } @@ -62,10 +61,9 @@ enum NotificationCategories { textInputPlaceholder: "Reply\u{2026}" ) - // Generic "Open" — used by CHAT_TURN_COMPLETED, CTO_MISSION_PHASE, and - // SYSTEM_ALERT. Per-category titles (e.g. "Open agent", "Open mission") are - // built inline below so the same action identifier can carry different - // labels; AppDelegate routes on identifier, not title. + // Generic "Open" — used by CHAT_TURN_COMPLETED and SYSTEM_ALERT. Per-category + // titles are built inline below so the same action identifier can carry + // different labels; AppDelegate routes on identifier, not title. let open = UNNotificationAction( identifier: Action.open, title: "Open", @@ -78,12 +76,6 @@ enum NotificationCategories { options: [.foreground] ) - let openMission = UNNotificationAction( - identifier: Action.open, - title: "Open mission", - options: [.foreground] - ) - let restart = UNNotificationAction( identifier: Action.restart, title: "Restart", @@ -159,21 +151,14 @@ enum NotificationCategories { let ctoSubagentFinished = UNNotificationCategory( identifier: Identifier.ctoSubagentFinished, - actions: [openMission], + actions: [openAgent], intentIdentifiers: [], options: [] ) let ctoSubagentStarted = UNNotificationCategory( identifier: Identifier.ctoSubagentStarted, - actions: [openMission], - intentIdentifiers: [], - options: [] - ) - - let ctoMissionPhase = UNNotificationCategory( - identifier: Identifier.ctoMissionPhase, - actions: [open], + actions: [openAgent], intentIdentifiers: [], options: [] ) @@ -195,7 +180,6 @@ enum NotificationCategories { prMergeReady, ctoSubagentStarted, ctoSubagentFinished, - ctoMissionPhase, systemAlert, ] } diff --git a/apps/ios/ADE/Models/NotificationPreferences.swift b/apps/ios/ADE/Models/NotificationPreferences.swift index 5190dab37..f7158fb95 100644 --- a/apps/ios/ADE/Models/NotificationPreferences.swift +++ b/apps/ios/ADE/Models/NotificationPreferences.swift @@ -25,7 +25,6 @@ public struct NotificationPreferences: Codable, Equatable, Hashable { // CTO & sub-agents public var ctoSubagentStarted: Bool = false public var ctoSubagentFinished: Bool = true - public var ctoMissionPhase: Bool = true // PRs & CI public var prCiFailing: Bool = true @@ -58,7 +57,6 @@ public struct NotificationPreferences: Codable, Equatable, Hashable { if chatTurnCompleted { n += 1 } if ctoSubagentStarted { n += 1 } if ctoSubagentFinished { n += 1 } - if ctoMissionPhase { n += 1 } if prCiFailing { n += 1 } if prReviewRequested { n += 1 } if prChangesRequested { n += 1 } @@ -71,7 +69,7 @@ public struct NotificationPreferences: Codable, Equatable, Hashable { /// Total category toggle count (excluding per-session overrides / quiet /// hours) — useful for "N of M" style UI. - public static let totalCategoryCount = 13 + public static let totalCategoryCount = 12 } public extension NotificationPreferences { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 72eaccfe4..925d60766 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -254,8 +254,6 @@ struct LaneSummary: Codable, Identifiable, Equatable { var createdAt: String var archivedAt: String? var devicesOpen: [DeviceMarker]? - var missionId: String? - var laneRole: String? } struct DeviceMarker: Codable, Identifiable, Equatable, Hashable { @@ -341,7 +339,6 @@ struct LaneRuntimeSummary: Codable, Equatable { struct LaneStateSnapshotSummary: Codable, Equatable { var laneId: String var agentSummary: [String: RemoteJSONValue]? - var missionSummary: [String: RemoteJSONValue]? var updatedAt: String? } @@ -946,8 +943,8 @@ struct LinearWorkflowDefinition: Codable, Hashable, Identifiable { private static func describeTarget(_ value: Any?) -> String { guard let dict = value as? [String: Any] else { return "—" } - // LinearWorkflowTarget typically has: { kind: "mission" | "worker_run" | ..., - // workerId?, missionTemplateId?, ... } + // LinearWorkflowTarget typically has: { kind: "worker_run" | ..., + // workerId?, ... } if let kind = dict["kind"] as? String { if let workerId = dict["workerId"] as? String, kind == "worker_run" { return "worker run · \(workerId)" @@ -2774,7 +2771,6 @@ struct QueueAutomationConfig: Codable, Equatable { var permissionMode: String? var confidenceThreshold: Double? var originSurface: String? - var originMissionId: String? var originRunId: String? var originLabel: String? } diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index b03bc5f6e..c451e1f76 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -25,21 +25,14 @@ create table if not exists lanes ( icon text, tags_json text, folder text, - mission_id text, - lane_role text, runtime_placement text not null default 'local', status text not null, created_at text not null, archived_at text, foreign key(project_id) references projects(id), - foreign key(parent_lane_id) references lanes(id), - foreign key(mission_id) references missions(id) on delete set null + foreign key(parent_lane_id) references lanes(id) ); -alter table lanes add column mission_id text; - -alter table lanes add column lane_role text; - alter table lanes add column runtime_placement text not null default 'local'; create index if not exists idx_lanes_project_id on lanes(project_id); @@ -48,10 +41,6 @@ create index if not exists idx_lanes_project_type on lanes(project_id, lane_type create index if not exists idx_lanes_project_parent on lanes(project_id, parent_lane_id); -create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id); - -create index if not exists idx_lanes_project_role on lanes(project_id, lane_role); - create table if not exists lane_linear_issues ( id text primary key, project_id text not null, @@ -152,7 +141,6 @@ create table if not exists lane_state_snapshots ( remote_behind integer not null default -1, rebase_in_progress integer not null default 0, agent_summary_json text, - mission_summary_json text, updated_at text not null, foreign key(lane_id) references lanes(id) ); @@ -836,99 +824,6 @@ create table if not exists rebase_deferred ( create index if not exists idx_rebase_deferred_project on rebase_deferred(project_id); -create table if not exists missions ( - id text primary key, - project_id text not null, - lane_id text, - mission_lane_id text, - result_lane_id text, - title text not null, - prompt text not null, - status text not null, - priority text not null default 'normal', - execution_mode text not null default 'local', - target_machine_id text, - queue_claim_token text, - queue_claimed_at text, - outcome_summary text, - last_error text, - metadata_json text, - created_at text not null, - updated_at text not null, - started_at text, - completed_at text, - archived_at text, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id), - foreign key(mission_lane_id) references lanes(id) on delete set null, - foreign key(result_lane_id) references lanes(id) on delete set null - ); - -alter table missions add column mission_lane_id text; - -alter table missions add column result_lane_id text; - -alter table missions add column queue_claim_token text; - -alter table missions add column queue_claimed_at text; - -alter table missions add column archived_at text; - -create index if not exists idx_missions_project_updated on missions(project_id, updated_at); - -create index if not exists idx_missions_project_status on missions(project_id, status); - -create index if not exists idx_missions_project_lane on missions(project_id, lane_id); - -create index if not exists idx_missions_project_mission_lane on missions(project_id, mission_lane_id); - -create index if not exists idx_missions_project_result_lane on missions(project_id, result_lane_id); - -drop index if exists idx_missions_queue_claim_token; - -create index if not exists idx_missions_project_queue_claim on missions(project_id, queue_claim_token); - -create table if not exists mission_steps ( - id text primary key, - mission_id text not null, - project_id text not null, - step_index integer not null, - title text not null, - detail text, - kind text not null default 'manual', - lane_id text, - status text not null, - metadata_json text, - created_at text not null, - updated_at text not null, - started_at text, - completed_at text, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id) - ); - -create index if not exists idx_mission_steps_mission_index on mission_steps(mission_id, step_index); - -create index if not exists idx_mission_steps_project_status on mission_steps(project_id, status); - -create table if not exists mission_events ( - id text primary key, - mission_id text not null, - project_id text not null, - event_type text not null, - actor text not null, - summary text not null, - payload_json text, - created_at text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id) - ); - -create index if not exists idx_mission_events_mission_created on mission_events(mission_id, created_at); - -create index if not exists idx_mission_events_project_created on mission_events(project_id, created_at); - create table if not exists computer_use_artifacts ( id text primary key, project_id text not null, @@ -968,53 +863,6 @@ create index if not exists idx_computer_use_artifact_links_owner on computer_use create index if not exists idx_computer_use_artifact_links_artifact on computer_use_artifact_links(artifact_id); -create table if not exists mission_artifacts ( - id text primary key, - mission_id text not null, - project_id text not null, - artifact_type text not null, - title text not null, - description text, - uri text, - lane_id text, - metadata_json text, - created_at text not null, - updated_at text not null, - created_by text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id) - ); - -create index if not exists idx_mission_artifacts_mission_created on mission_artifacts(mission_id, created_at); - -create table if not exists mission_interventions ( - id text primary key, - mission_id text not null, - project_id text not null, - intervention_type text not null, - status text not null, - resolution_kind text, - title text not null, - body text not null, - requested_action text, - resolution_note text, - lane_id text, - metadata_json text, - created_at text not null, - updated_at text not null, - resolved_at text, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(lane_id) references lanes(id) - ); - -alter table mission_interventions add column resolution_kind text; - -create index if not exists idx_mission_interventions_mission_status on mission_interventions(mission_id, status); - -create index if not exists idx_mission_interventions_project_status on mission_interventions(project_id, status); - create table if not exists phase_cards ( id text primary key, project_id text not null, @@ -1056,27 +904,9 @@ create index if not exists idx_phase_profiles_project_updated on phase_profiles( create index if not exists idx_phase_profiles_project_default on phase_profiles(project_id, is_default); -create table if not exists mission_phase_overrides ( - id text primary key, - mission_id text not null, - project_id text not null, - profile_id text, - phases_json text not null, - created_at text not null, - updated_at text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id), - foreign key(profile_id) references phase_profiles(id) - ); - -create index if not exists idx_mission_phase_overrides_project_mission on mission_phase_overrides(project_id, mission_id); - -create index if not exists idx_mission_phase_overrides_profile on mission_phase_overrides(profile_id); - create table if not exists orchestrator_runs ( id text primary key, project_id text not null, - mission_id text not null, status text not null, context_profile text not null default 'orchestrator_deterministic_v1', scheduler_state text not null, @@ -1087,21 +917,17 @@ create table if not exists orchestrator_runs ( updated_at text not null, started_at text, completed_at text, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade + foreign key(project_id) references projects(id) ); create index if not exists idx_orchestrator_runs_project_status on orchestrator_runs(project_id, status); -create index if not exists idx_orchestrator_runs_mission on orchestrator_runs(mission_id); - create index if not exists idx_orchestrator_runs_project_updated on orchestrator_runs(project_id, updated_at); create table if not exists orchestrator_steps ( id text primary key, run_id text not null, project_id text not null, - mission_step_id text, step_key text not null, step_index integer not null, title text not null, @@ -1121,7 +947,6 @@ create table if not exists orchestrator_steps ( completed_at text, foreign key(run_id) references orchestrator_runs(id), foreign key(project_id) references projects(id), - foreign key(mission_step_id) references mission_steps(id), foreign key(lane_id) references lanes(id) ); @@ -1259,32 +1084,6 @@ create index if not exists idx_orchestrator_context_snapshots_run_created on orc create index if not exists idx_orchestrator_context_snapshots_attempt on orchestrator_context_snapshots(attempt_id); -create table if not exists mission_step_handoffs ( - id text primary key, - project_id text not null, - mission_id text not null, - mission_step_id text, - run_id text, - step_id text, - attempt_id text, - handoff_type text not null, - producer text not null, - payload_json text not null, - created_at text not null, - foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(mission_step_id) references mission_steps(id), - foreign key(run_id) references orchestrator_runs(id), - foreign key(step_id) references orchestrator_steps(id), - foreign key(attempt_id) references orchestrator_attempts(id) - ); - -create index if not exists idx_mission_step_handoffs_mission_created on mission_step_handoffs(mission_id, created_at); - -create index if not exists idx_mission_step_handoffs_step_created on mission_step_handoffs(mission_step_id, created_at); - -create index if not exists idx_mission_step_handoffs_attempt on mission_step_handoffs(attempt_id); - create table if not exists orchestrator_timeline_events ( id text primary key, project_id text not null, @@ -1322,7 +1121,6 @@ create index if not exists idx_orchestrator_gate_reports_project_generated on or create table if not exists orchestrator_chat_threads ( id text primary key, project_id text not null, - mission_id text not null, thread_type text not null, title text not null, run_id text, @@ -1337,25 +1135,17 @@ create table if not exists orchestrator_chat_threads ( created_at text not null, updated_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(step_id) references orchestrator_steps(id), foreign key(attempt_id) references orchestrator_attempts(id), foreign key(lane_id) references lanes(id) ); -create index if not exists idx_orchestrator_chat_threads_mission_updated on orchestrator_chat_threads(mission_id, updated_at); - -create index if not exists idx_orchestrator_chat_threads_project_mission on orchestrator_chat_threads(project_id, mission_id); - -create index if not exists idx_orchestrator_chat_threads_mission_type on orchestrator_chat_threads(mission_id, thread_type); - create index if not exists idx_orchestrator_chat_threads_lane on orchestrator_chat_threads(lane_id); create table if not exists orchestrator_chat_messages ( id text primary key, project_id text not null, - mission_id text not null, thread_id text not null, role text not null, content text not null, @@ -1371,7 +1161,6 @@ create table if not exists orchestrator_chat_messages ( metadata_json text, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(thread_id) references orchestrator_chat_threads(id), foreign key(attempt_id) references orchestrator_attempts(id), foreign key(lane_id) references lanes(id), @@ -1380,18 +1169,15 @@ create table if not exists orchestrator_chat_messages ( create index if not exists idx_orchestrator_chat_messages_thread_ts on orchestrator_chat_messages(thread_id, timestamp); -create index if not exists idx_orchestrator_chat_messages_mission_ts on orchestrator_chat_messages(mission_id, timestamp); - create index if not exists idx_orchestrator_chat_messages_attempt_ts on orchestrator_chat_messages(attempt_id, timestamp); create index if not exists idx_orchestrator_chat_messages_lane_ts on orchestrator_chat_messages(lane_id, timestamp); -create index if not exists idx_orchestrator_chat_messages_delivery_queue on orchestrator_chat_messages(delivery_state, role, mission_id, thread_id, timestamp); +create index if not exists idx_orchestrator_chat_messages_delivery_queue on orchestrator_chat_messages(delivery_state, role, thread_id, timestamp); create table if not exists orchestrator_worker_digests ( id text primary key, project_id text not null, - mission_id text not null, run_id text not null, step_id text not null, step_key text, @@ -1408,15 +1194,12 @@ create table if not exists orchestrator_worker_digests ( suggested_next_actions_json text not null, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(step_id) references orchestrator_steps(id), foreign key(attempt_id) references orchestrator_attempts(id), foreign key(lane_id) references lanes(id) ); -create index if not exists idx_orchestrator_worker_digests_mission_created on orchestrator_worker_digests(mission_id, created_at); - create index if not exists idx_orchestrator_worker_digests_run_created on orchestrator_worker_digests(run_id, created_at); create index if not exists idx_orchestrator_worker_digests_attempt on orchestrator_worker_digests(attempt_id); @@ -1426,7 +1209,6 @@ create index if not exists idx_orchestrator_worker_digests_lane_created on orche create table if not exists orchestrator_artifacts ( id text primary key, project_id text not null, - mission_id text not null, run_id text not null, step_id text not null, attempt_id text not null, @@ -1437,40 +1219,30 @@ create table if not exists orchestrator_artifacts ( declared integer not null default 0, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(step_id) references orchestrator_steps(id), foreign key(attempt_id) references orchestrator_attempts(id) ); -create index if not exists idx_orchestrator_artifacts_mission_created on orchestrator_artifacts(mission_id, created_at); - create index if not exists idx_orchestrator_artifacts_step on orchestrator_artifacts(step_id); -create index if not exists idx_orchestrator_artifacts_mission_key on orchestrator_artifacts(mission_id, artifact_key); - create table if not exists orchestrator_context_checkpoints ( id text primary key, project_id text not null, - mission_id text not null, run_id text, trigger text not null, summary text not null, source_json text not null, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id) ); -create index if not exists idx_orchestrator_context_checkpoints_mission_created on orchestrator_context_checkpoints(mission_id, created_at); - create index if not exists idx_orchestrator_context_checkpoints_run_created on orchestrator_context_checkpoints(run_id, created_at); create table if not exists orchestrator_worker_checkpoints ( id text primary key, project_id text not null, - mission_id text not null, run_id text not null, step_id text not null, attempt_id text not null, @@ -1480,22 +1252,16 @@ create table if not exists orchestrator_worker_checkpoints ( created_at text not null, updated_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(step_id) references orchestrator_steps(id), foreign key(attempt_id) references orchestrator_attempts(id) ); -create index if not exists idx_orchestrator_worker_checkpoints_mission_step_key on orchestrator_worker_checkpoints(mission_id, step_key); - create index if not exists idx_orchestrator_worker_checkpoints_run on orchestrator_worker_checkpoints(run_id); -create index if not exists idx_orchestrator_worker_checkpoints_mission on orchestrator_worker_checkpoints(mission_id, updated_at); - create table if not exists orchestrator_lane_decisions ( id text primary key, project_id text not null, - mission_id text not null, run_id text, step_id text, step_key text, @@ -1507,14 +1273,11 @@ create table if not exists orchestrator_lane_decisions ( metadata_json text, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(step_id) references orchestrator_steps(id), foreign key(lane_id) references lanes(id) ); -create index if not exists idx_orchestrator_lane_decisions_mission_created on orchestrator_lane_decisions(mission_id, created_at); - create index if not exists idx_orchestrator_lane_decisions_run_created on orchestrator_lane_decisions(run_id, created_at); create index if not exists idx_orchestrator_lane_decisions_step_created on orchestrator_lane_decisions(step_id, created_at); @@ -1524,7 +1287,6 @@ create index if not exists idx_orchestrator_lane_decisions_lane_created on orche create table if not exists orchestrator_ai_decisions ( id text primary key, project_id text not null, - mission_id text not null, run_id text, step_id text, attempt_id text, @@ -1543,14 +1305,11 @@ create table if not exists orchestrator_ai_decisions ( completion_tokens integer, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(step_id) references orchestrator_steps(id), foreign key(attempt_id) references orchestrator_attempts(id) ); -create index if not exists idx_orchestrator_ai_decisions_mission_created on orchestrator_ai_decisions(mission_id, created_at); - create index if not exists idx_orchestrator_ai_decisions_run_created on orchestrator_ai_decisions(run_id, created_at); create index if not exists idx_orchestrator_ai_decisions_step_created on orchestrator_ai_decisions(step_id, created_at); @@ -1559,21 +1318,9 @@ create index if not exists idx_orchestrator_ai_decisions_project_category_create create index if not exists idx_orchestrator_ai_decisions_created on orchestrator_ai_decisions(created_at); -create table if not exists mission_metrics_config ( - mission_id text primary key, - project_id text not null, - toggles_json text not null, - updated_at text not null, - foreign key(mission_id) references missions(id) on delete cascade, - foreign key(project_id) references projects(id) - ); - -create index if not exists idx_mission_metrics_config_project_updated on mission_metrics_config(project_id, updated_at); - create table if not exists orchestrator_metrics_samples ( id text primary key, project_id text not null, - mission_id text not null, run_id text, attempt_id text, metric text not null, @@ -1582,13 +1329,10 @@ create table if not exists orchestrator_metrics_samples ( metadata_json text, created_at text not null, foreign key(project_id) references projects(id), - foreign key(mission_id) references missions(id) on delete cascade, foreign key(run_id) references orchestrator_runs(id), foreign key(attempt_id) references orchestrator_attempts(id) ); -create index if not exists idx_orchestrator_metrics_samples_mission_created on orchestrator_metrics_samples(mission_id, created_at); - create index if not exists idx_orchestrator_metrics_samples_run_created on orchestrator_metrics_samples(run_id, created_at); create index if not exists idx_orchestrator_metrics_samples_metric_created on orchestrator_metrics_samples(metric, created_at); @@ -1638,7 +1382,6 @@ create index if not exists idx_agent_identities_project on agent_identities(proj create table if not exists orchestrator_team_members ( id text primary key, run_id text not null, - mission_id text not null, provider text not null, model text not null, role text not null default 'teammate', @@ -1648,20 +1391,16 @@ create table if not exists orchestrator_team_members ( metadata_json text, created_at text not null, updated_at text not null, - foreign key(run_id) references orchestrator_runs(id), - foreign key(mission_id) references missions(id) on delete cascade + foreign key(run_id) references orchestrator_runs(id) ); create index if not exists idx_orchestrator_team_members_run on orchestrator_team_members(run_id); -create index if not exists idx_orchestrator_team_members_mission on orchestrator_team_members(mission_id); - create index if not exists idx_orchestrator_team_members_status on orchestrator_team_members(run_id, status); create table if not exists orchestrator_reflections ( id text primary key, project_id text not null, - mission_id text not null, run_id text not null, step_id text, attempt_id text, @@ -1679,12 +1418,9 @@ create table if not exists orchestrator_reflections ( create index if not exists idx_orchestrator_reflections_run_occurred on orchestrator_reflections(run_id, occurred_at); -create index if not exists idx_orchestrator_reflections_mission on orchestrator_reflections(mission_id, occurred_at); - create table if not exists orchestrator_retrospectives ( id text primary key, project_id text not null, - mission_id text not null, run_id text not null, generated_at text not null, final_status text not null, @@ -1694,15 +1430,11 @@ create table if not exists orchestrator_retrospectives ( foreign key(run_id) references orchestrator_runs(id) ); -create index if not exists idx_orchestrator_retrospectives_mission_generated on orchestrator_retrospectives(mission_id, generated_at); - create table if not exists orchestrator_retrospective_trends ( id text primary key, project_id text not null, - mission_id text not null, run_id text not null, retrospective_id text not null, - source_mission_id text not null, source_run_id text not null, source_retrospective_id text not null, pain_point_key text not null, @@ -1713,8 +1445,6 @@ create table if not exists orchestrator_retrospective_trends ( created_at text not null ); -create index if not exists idx_orchestrator_retrospective_trends_mission_created on orchestrator_retrospective_trends(mission_id, created_at); - create index if not exists idx_orchestrator_retrospective_trends_run_created on orchestrator_retrospective_trends(run_id, created_at); create table if not exists orchestrator_reflection_pattern_stats ( @@ -1738,7 +1468,6 @@ create table if not exists orchestrator_reflection_pattern_sources ( project_id text not null, pattern_stat_id text not null, retrospective_id text not null, - mission_id text not null, run_id text not null, created_at text not null, foreign key(pattern_stat_id) references orchestrator_reflection_pattern_stats(id) @@ -1746,8 +1475,6 @@ create table if not exists orchestrator_reflection_pattern_sources ( create index if not exists idx_orchestrator_reflection_pattern_sources_pattern on orchestrator_reflection_pattern_sources(pattern_stat_id, created_at); -create index if not exists idx_orchestrator_reflection_pattern_sources_mission on orchestrator_reflection_pattern_sources(mission_id, created_at); - create table if not exists orchestrator_run_state ( run_id text primary key, phase text not null default 'bootstrapping', @@ -1973,7 +1700,6 @@ create table if not exists linear_dispatch_queue ( action text not null, worker_id text, worker_slug text, - mission_id text, route_json text not null default '{}', attempt_count integer not null default 0, next_attempt_at text, @@ -1994,7 +1720,6 @@ create table if not exists linear_issue_claims ( queue_item_id text, worker_id text, worker_slug text, - mission_id text, linear_assignee_id text, status text not null default 'active', claimed_at text not null, @@ -2052,7 +1777,6 @@ create table if not exists linear_workflow_runs ( current_step_index integer not null default 0, current_step_id text, execution_lane_id text, - linked_mission_id text, linked_session_id text, linked_worker_run_id text, linked_pr_id text, diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 3d05d3114..a0d942469 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -617,8 +617,6 @@ final class DatabaseService { icon = excluded.icon, tags_json = excluded.tags_json, folder = excluded.folder, - mission_id = null, - lane_role = null, status = excluded.status, created_at = excluded.created_at, archived_at = excluded.archived_at @@ -678,8 +676,8 @@ final class DatabaseService { _ = try execute(""" insert into lane_state_snapshots ( lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, - agent_summary_json, mission_summary_json, updated_at - ) values (?, ?, ?, ?, ?, ?, null, null, ?) + agent_summary_json, updated_at + ) values (?, ?, ?, ?, ?, ?, null, ?) on conflict(lane_id) do update set dirty = excluded.dirty, ahead = excluded.ahead, @@ -687,7 +685,6 @@ final class DatabaseService { remote_behind = excluded.remote_behind, rebase_in_progress = excluded.rebase_in_progress, agent_summary_json = excluded.agent_summary_json, - mission_summary_json = excluded.mission_summary_json, updated_at = excluded.updated_at """) { statement in try bindText(lane.id, to: statement, index: 1) @@ -2117,7 +2114,6 @@ final class DatabaseService { permissionMode: nil, confidenceThreshold: nil, originSurface: nil, - originMissionId: nil, originRunId: nil, originLabel: nil ), @@ -2264,16 +2260,6 @@ final class DatabaseService { columnName: "folder", definition: "text" ) - try ensureColumn( - tableName: "lanes", - columnName: "mission_id", - definition: "text" - ) - try ensureColumn( - tableName: "lanes", - columnName: "lane_role", - definition: "text" - ) try ensureColumn( tableName: "terminal_sessions", columnName: "lane_name", @@ -2424,14 +2410,6 @@ final class DatabaseService { try ensureColumn(tableName: "queue_landing_state", columnName: "updated_at", definition: "text") try exec("create index if not exists idx_pull_requests_project_updated on pull_requests(project_id, updated_at desc)") try exec("create index if not exists idx_queue_landing_state_project_updated on queue_landing_state(project_id, updated_at desc, started_at desc)") - - try ensureColumn(tableName: "missions", columnName: "mission_lane_id", definition: "text") - try ensureColumn(tableName: "missions", columnName: "result_lane_id", definition: "text") - try ensureColumn(tableName: "missions", columnName: "queue_claim_token", definition: "text") - try ensureColumn(tableName: "missions", columnName: "queue_claimed_at", definition: "text") - try ensureColumn(tableName: "missions", columnName: "archived_at", definition: "text") - - try ensureColumn(tableName: "mission_interventions", columnName: "resolution_kind", definition: "text") try ensureColumn(tableName: "worker_agents", columnName: "linear_identity_json", definition: "text not null default '{}'") } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 9d9ca7b82..3d835488e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3059,7 +3059,7 @@ final class SyncService: ObservableObject { throw NSError(domain: "ADE", code: 118, userInfo: [NSLocalizedDescriptionKey: "The selected Files workspace is no longer available on this phone."]) } guard !workspace.readOnlyOnMobile else { - throw NSError(domain: "ADE", code: 119, userInfo: [NSLocalizedDescriptionKey: "Files stays read-only on iPhone for this mission."]) + throw NSError(domain: "ADE", code: 119, userInfo: [NSLocalizedDescriptionKey: "Files stays read-only on iPhone for this workspace."]) } } @@ -6638,7 +6638,7 @@ final class SyncService: ObservableObject { case "chat": return NotificationCategories.Identifier.chatAwaitingInput case "cto": - return NotificationCategories.Identifier.ctoMissionPhase + return NotificationCategories.Identifier.ctoSubagentFinished case "pr": return NotificationCategories.Identifier.prReviewRequested default: @@ -7803,7 +7803,6 @@ extension SyncService { "cto": [ "subagentStarted": prefs.ctoSubagentStarted, "subagentFinished": prefs.ctoSubagentFinished, - "missionPhaseChanged": prefs.ctoMissionPhase, ], "prs": [ "ciFailing": prefs.prCiFailing, diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 1d6ca8806..8c721ef3d 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -81,7 +81,6 @@ enum ADEColor { static let tintPRs = purpleAccent static let tintHistory = Color(red: 0xd9 / 255.0, green: 0x77 / 255.0, blue: 0x06 / 255.0) static let tintAutomations = Color(red: 0xf9 / 255.0, green: 0x73 / 255.0, blue: 0x16 / 255.0) - static let tintMissions = Color(red: 0x3b / 255.0, green: 0x82 / 255.0, blue: 0xf6 / 255.0) static let tintSettings = Color(red: 0x71 / 255.0, green: 0x71 / 255.0, blue: 0x7a / 255.0) /// Per-provider brand colors mirroring desktop's PROVIDER_BADGE_COLORS. diff --git a/apps/ios/ADE/Views/Cto/CtoSharedHelpers.swift b/apps/ios/ADE/Views/Cto/CtoSharedHelpers.swift index c3090e65c..58eb3c33c 100644 --- a/apps/ios/ADE/Views/Cto/CtoSharedHelpers.swift +++ b/apps/ios/ADE/Views/Cto/CtoSharedHelpers.swift @@ -30,7 +30,7 @@ let ctoStatusRunningBlue = Color(red: 0x60 / 255.0, green: 0xA5 / 255.0, blue: 0 let ctoAvatarPalette: [Color] = [ ADEColor.ctoAccent, - ADEColor.tintMissions, + ADEColor.info, ADEColor.tintLanes, ADEColor.tintWork, ADEColor.tintHistory, diff --git a/apps/ios/ADE/Views/Lanes/LaneActionsCard.swift b/apps/ios/ADE/Views/Lanes/LaneActionsCard.swift index de8596fb5..c08141207 100644 --- a/apps/ios/ADE/Views/Lanes/LaneActionsCard.swift +++ b/apps/ios/ADE/Views/Lanes/LaneActionsCard.swift @@ -11,8 +11,6 @@ struct LaneActionsCard: View { var laneId: String? = nil var branchRef: String? = nil var laneType: String? = nil - var missionId: String? = nil - var laneRole: String? = nil var onRefresh: (@MainActor () async -> Void)? = nil @State private var moreExpanded = false @@ -51,15 +49,6 @@ struct LaneActionsCard: View { if laneType == "attached" { return "Branch switching is disabled for attached lanes — manage this worktree with your own tools." } - if missionId != nil, laneRole == "result" { - return "Branch switching is disabled for mission result lanes to keep their output stable." - } - // Only block when we *know* the lane is a mission worker (laneRole is set - // to a non-"result" value). Treat a missing/unknown role as "allowed" — - // older callers without a role shouldn't be silently locked out. - if missionId != nil, let role = laneRole, role != "result" { - return "Branch switching isn't available on mission worker lanes." - } return nil } diff --git a/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift b/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift index 0e01abd32..9b874570d 100644 --- a/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift @@ -10,8 +10,6 @@ struct LaneAdvancedScreen: View { let laneId: String let branchRef: String? let laneType: String? - let missionId: String? - let laneRole: String? let onOpenManageSheet: () -> Void let onSwitchBranch: () -> Void let onStash: () -> Void @@ -195,16 +193,6 @@ struct LaneAdvancedScreen: View { if laneType == "attached" { return "Branch switching is disabled for attached lanes." } - if missionId != nil { - // Mission lanes default to disabled. Only the explicit "result" role - // gets the result-specific copy; every other role (or a missing role) - // falls into the worker-lane bucket so we never accidentally allow - // branch switching on a mission lane with an unknown role. - if laneRole == "result" { - return "Branch switching is disabled for mission result lanes." - } - return "Branch switching isn't available on mission worker lanes." - } return nil } } diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift index c82cba08d..cbb494d88 100644 --- a/apps/ios/ADE/Views/Lanes/LaneComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -281,7 +281,7 @@ struct LaneListRow: View, Equatable { .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) - laneRoleBadge + laneTypeBadge Spacer(minLength: 0) } @@ -362,7 +362,7 @@ struct LaneListRow: View, Equatable { } @ViewBuilder - private var laneRoleBadge: some View { + private var laneTypeBadge: some View { if snapshot.lane.laneType == "primary" { LaneTypeBadge(text: "Primary", tint: ADEColor.accent) } else if snapshot.lane.laneType == "attached" { @@ -543,7 +543,7 @@ struct LaneStackCard: View, Equatable { .lineLimit(1) .adeMatchedGeometry(id: isSelectedTransitionSource ? "lane-title-\(snapshot.lane.id)" : nil, in: transitionNamespace) - laneRoleBadge + laneTypeBadge Spacer(minLength: 4) @@ -642,7 +642,7 @@ struct LaneStackCard: View, Equatable { } @ViewBuilder - private var laneRoleBadge: some View { + private var laneTypeBadge: some View { if snapshot.lane.laneType == "attached" { LaneTypeBadge(text: "Attached", tint: ADEColor.textMuted) } else if snapshot.lane.archivedAt != nil { diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift index c27c3cc27..2b9990d68 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift @@ -102,8 +102,6 @@ extension LaneDetailScreen { laneId: laneId, branchRef: detail.lane.branchRef, laneType: detail.lane.laneType, - missionId: detail.lane.missionId, - laneRole: detail.lane.laneRole, onOpenManageSheet: { managePresented = true }, onSwitchBranch: { showBranchPicker = true }, onStash: { diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index d146fc6cc..dc89da38d 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -25,9 +25,6 @@ func laneActivitySummary(_ snapshot: LaneListSnapshot) -> String? { if let agentText = summarizeState(snapshot.stateSnapshot?.agentSummary) { return agentText } - if let missionText = summarizeState(snapshot.stateSnapshot?.missionSummary) { - return missionText - } return nil } @@ -162,7 +159,6 @@ func matchesLaneToken(snapshot: LaneListSnapshot, isPinned: Bool, token: String) snapshot.runtime.bucket, "\(snapshot.runtime.sessionCount)", summarizeState(snapshot.stateSnapshot?.agentSummary) ?? "", - summarizeState(snapshot.stateSnapshot?.missionSummary) ?? "", isPinned ? "pinned" : "", ].joined(separator: " ").lowercased() return indexed.contains(token) diff --git a/apps/ios/ADE/Views/PRs/PrStackDiagramView.swift b/apps/ios/ADE/Views/PRs/PrStackDiagramView.swift index d5d36b239..5b29e01d3 100644 --- a/apps/ios/ADE/Views/PRs/PrStackDiagramView.swift +++ b/apps/ios/ADE/Views/PRs/PrStackDiagramView.swift @@ -154,7 +154,7 @@ private struct PrStackStatePill: View { PrStackNode(id: "2", label: "#309 · Schema migration v3", branch: "integration/schema-v3", state: "open", adeKind: "integration", kindColor: PrGlassPalette.warning, subMetric: "base · awaiting 1 child", indent: 0, isRoot: true), PrStackNode(id: "3", label: "#316 · Fix auth middleware ordering", branch: "lane/auth-fix", state: "open", adeKind: "worker", kindColor: PrGlassPalette.purple, subMetric: "12 ✓ · 1 approval · ready", indent: 1), PrStackNode(id: "4", label: "#315 · Add payments idempotency", branch: "lane/payments", state: "draft", adeKind: "lane", kindColor: PrGlassPalette.success, subMetric: "8 ✓ · draft", indent: 1), - PrStackNode(id: "5", label: "#318 · Rename preferences", branch: "lane/rename-prefs", state: "blocked", adeKind: "mission", kindColor: PrGlassPalette.purpleBright, subMetric: "2 ✗ · blocked", indent: 1, isLast: true), + PrStackNode(id: "5", label: "#318 · Rename preferences", branch: "lane/rename-prefs", state: "blocked", adeKind: "lane", kindColor: PrGlassPalette.purpleBright, subMetric: "2 ✗ · blocked", indent: 1, isLast: true), ]) .padding() .background(PrGlassPalette.ink) diff --git a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift index 38b8187aa..04dccec73 100644 --- a/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift +++ b/apps/ios/ADE/Views/Settings/NotificationsCenterView.swift @@ -69,12 +69,6 @@ struct NotificationsCenterView: View { subtitle: nil, toggle: binding(\.ctoSubagentFinished) ) - rowSeparator() - settingsRow( - title: "Mission phase change", - subtitle: nil, - toggle: binding(\.ctoMissionPhase) - ) } section(title: "Pull requests") { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 684267155..403bb450e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1649,7 +1649,7 @@ final class ADETests: XCTestCase { "event": { "type": "user_message", "text": "INTERNAL_RUNTIME_PROMPT", - "displayText": "ADE coordinator tick: review mission state and route the next action.", + "displayText": "ADE coordinator tick: review agent state and route the next action.", "turnId": "turn-1" } } @@ -1659,7 +1659,7 @@ final class ADETests: XCTestCase { guard case .userMessage(let userText, _, let userTurnId, _, _, _) = userMessageEnvelope.event else { return XCTFail("Expected user message event.") } - XCTAssertEqual(userText, "ADE coordinator tick: review mission state and route the next action.") + XCTAssertEqual(userText, "ADE coordinator tick: review agent state and route the next action.") XCTAssertEqual(userTurnId, "turn-1") let resolvedJSON = """ @@ -3701,7 +3701,6 @@ final class ADETests: XCTestCase { stateSnapshot: LaneStateSnapshotSummary( laneId: "lane-child", agentSummary: ["summary": .string("Codex running")], - missionSummary: ["summary": .string("Ship W6")], updatedAt: "2026-03-18T00:14:00.000Z" ), adoptableAttached: false @@ -3810,7 +3809,6 @@ final class ADETests: XCTestCase { stateSnapshot: LaneStateSnapshotSummary( laneId: "lane-primary", agentSummary: ["summary": .string("Awaiting review")], - missionSummary: nil, updatedAt: "2026-03-18T00:20:00.000Z" ), rebaseSuggestion: nil, @@ -4836,7 +4834,6 @@ final class ADETests: XCTestCase { stateSnapshot: LaneStateSnapshotSummary( laneId: "lane-attached-active", agentSummary: ["summary": .string("Agent waiting on approval")], - missionSummary: ["summary": .string("Ship the cleanup")], updatedAt: nil ), createdAt: "2026-03-20T00:00:00.000Z", @@ -4855,8 +4852,7 @@ final class ADETests: XCTestCase { runtime: LaneRuntimeSummary(bucket: "awaiting-input", runningCount: 0, awaitingInputCount: 1, endedCount: 0, sessionCount: 1), stateSnapshot: LaneStateSnapshotSummary( laneId: "lane-worktree", - agentSummary: ["title": .string("Codex")], - missionSummary: ["objective": .string("Handle OAuth redirects")], + agentSummary: ["title": .string("Codex"), "objective": .string("Handle OAuth redirects")], updatedAt: nil ), createdAt: "2026-03-10T00:00:00.000Z", @@ -6818,7 +6814,7 @@ final class ADETests: XCTestCase { func testParseWorkChatTranscriptPrefersUserMessageDisplayText() { let raw = """ - {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"user_message","text":"INTERNAL_RUNTIME_PROMPT","displayText":"ADE coordinator start: initialize the mission.","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"user_message","text":"INTERNAL_RUNTIME_PROMPT","displayText":"ADE coordinator start: initialize the session.","turnId":"turn-1"}} """ let transcript = parseWorkChatTranscript(raw) @@ -6827,7 +6823,7 @@ final class ADETests: XCTestCase { guard case .userMessage(let text, let turnId, _, _, _) = transcript[0].event else { return XCTFail("Expected user_message event.") } - XCTAssertEqual(text, "ADE coordinator start: initialize the mission.") + XCTAssertEqual(text, "ADE coordinator start: initialize the session.") XCTAssertEqual(turnId, "turn-1") } @@ -8488,7 +8484,6 @@ final class ADETests: XCTestCase { remote_behind integer not null default -1, rebase_in_progress integer not null default 0, agent_summary_json text, - mission_summary_json text, updated_at text not null default '' ); """) @@ -8643,7 +8638,6 @@ final class ADETests: XCTestCase { remote_behind integer not null default -1, rebase_in_progress integer not null default 0, agent_summary_json text, - mission_summary_json text, updated_at text not null ); """) @@ -8687,7 +8681,6 @@ final class ADETests: XCTestCase { remote_behind integer not null default -1, rebase_in_progress integer not null default 0, agent_summary_json text, - mission_summary_json text, updated_at text not null ); create table if not exists terminal_sessions ( @@ -8778,9 +8771,9 @@ final class ADETests: XCTestCase { 'active', '2026-03-17T00:00:00.000Z', null ); insert into lane_state_snapshots ( - lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at + lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, updated_at ) values ( - 'lane-primary', 0, 0, 0, 0, 0, null, null, '2026-03-17T00:00:00.000Z' + 'lane-primary', 0, 0, 0, 0, 0, null, '2026-03-17T00:00:00.000Z' ); """) } @@ -9486,7 +9479,7 @@ final class ADETests: XCTestCase { "allowsFreeform": true, "options": [ {"label":"Chat / Messaging","value":"chat","description":"Testing the chat composer, message sending, or conversation flow"}, - {"label":"Lanes / Missions","value":"lanes","description":"Testing lane creation, mission management, or task flow"}, + {"label":"Lanes","value":"lanes","description":"Testing lane creation, branch management, or task flow"}, {"label":"Sync / Connectivity","value":"sync","description":"Testing device sync, WebSocket connection, or host pairing"}, {"label":"Something else","value":"other","description":"A different part of the app not listed above"} ] diff --git a/apps/web/index.html b/apps/web/index.html index 3b32b1284..278185577 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,13 +7,13 @@ <title>ADE — Agentic Development Environment @@ -25,7 +25,7 @@ diff --git a/apps/web/public/images/features/missions.png b/apps/web/public/images/features/missions.png deleted file mode 100644 index 3d8c94049..000000000 Binary files a/apps/web/public/images/features/missions.png and /dev/null differ diff --git a/apps/web/src/components/SiteFooter.tsx b/apps/web/src/components/SiteFooter.tsx index c47ed6fd8..91aa0bfa1 100644 --- a/apps/web/src/components/SiteFooter.tsx +++ b/apps/web/src/components/SiteFooter.tsx @@ -24,7 +24,7 @@ export function SiteFooter() {

The all-in-one AI development environment. Agent chat, worktrees, - missions, automations, and 35+ built-in tools. + automations, and 35+ built-in tools.

diff --git a/apps/web/src/components/editorial/FeatureGrid.tsx b/apps/web/src/components/editorial/FeatureGrid.tsx index 596401447..1997dd46d 100644 --- a/apps/web/src/components/editorial/FeatureGrid.tsx +++ b/apps/web/src/components/editorial/FeatureGrid.tsx @@ -52,12 +52,6 @@ const FEATURES: Feature[] = [ src: "/images/features/linear-sync.png", docs: "/cto/linear", }, - { - label: "Missions", - blurb: "Long-running agent workflows — plan, build, test, ship. Spans multiple worktrees.", - src: "/images/features/missions.png", - docs: "/missions/overview", - }, { label: "Automations", blurb: "Run agents on a schedule or a trigger. They ship the PR and ping you when it's done.", diff --git a/apps/web/src/components/editorial/IndexPage.tsx b/apps/web/src/components/editorial/IndexPage.tsx index ab7112934..b0c59648e 100644 --- a/apps/web/src/components/editorial/IndexPage.tsx +++ b/apps/web/src/components/editorial/IndexPage.tsx @@ -10,29 +10,23 @@ const ENTRIES: Entry[] = [ { name: "Computer Use", page: "30", href: "/computer-use/overview" }, { name: "CTO (chief technical officer)", page: "15", href: "/cto/overview" }, { name: "CLI · ade", page: "12", href: "/tools/project-home" }, - { name: "Dispatch", page: "17", href: "/missions/creating" }, { name: "File viewer", page: "05", href: "/tools/files-editor" }, { name: "Git history", page: "07", href: "/tools/history" }, { name: "iOS app", page: "28", href: "/getting-started/install" }, { name: "Linear sync", page: "18", href: "/cto/linear" }, { name: "Local models", page: "23", href: "/configuration/ai-providers" }, { name: "Merge conflicts", page: "26", href: "/tools/conflicts" }, - { name: "Missions", page: "14", href: "/missions/overview" }, { name: "Mobile sync", page: "28", href: "/tools/project-home" }, { name: "Model configuration", page: "22", href: "/configuration/ai-providers" }, { name: "Multi-provider chat", page: "08", href: "/chat/capabilities" }, - { name: "Orchestrator", page: "14", href: "/missions/overview" }, { name: "PR review", page: "20", href: "/tools/pull-requests" }, - { name: "Planning phase", page: "14", href: "/missions/creating" }, { name: "Play runtime", page: "25", href: "/automations/executors" }, { name: "Providers", page: "22", href: "/configuration/ai-providers" }, { name: "Proof drawer", page: "21", href: "/computer-use/proofs" }, { name: "Screenshots (computer use)", page: "30", href: "/computer-use/overview" }, { name: "Settings", page: "22", href: "/configuration/settings" }, - { name: "Sub-agents", page: "17", href: "/missions/workers" }, { name: "Team (CTO org)", page: "15", href: "/cto/workers" }, { name: "Terminals", page: "10", href: "/tools/terminals" }, - { name: "Testing phase", page: "14", href: "/missions/overview" }, { name: "Worktrees", page: "04", href: "/lanes/overview" }, { name: "Workspace graph", page: "06", href: "/tools/workspace-graph" }, ]; diff --git a/apps/web/src/components/ui/AppScreenshot.tsx b/apps/web/src/components/ui/AppScreenshot.tsx index 3c18354fb..cab4745c7 100644 --- a/apps/web/src/components/ui/AppScreenshot.tsx +++ b/apps/web/src/components/ui/AppScreenshot.tsx @@ -75,7 +75,7 @@ export function AppScreenshot() {
import
{"{"} Page {"}"}
from
"../../components/Page";

export function
HomePage() {"{"}
-   useDocumentTitle("Mission Control");
+   useDocumentTitle("ADE");

  
return
(
    <Page className="overflow-hidden">
diff --git a/apps/web/src/components/ui/HeroVisual.tsx b/apps/web/src/components/ui/HeroVisual.tsx index cd6af5fe6..7f4654e76 100644 --- a/apps/web/src/components/ui/HeroVisual.tsx +++ b/apps/web/src/components/ui/HeroVisual.tsx @@ -1,7 +1,7 @@ import { motion } from "framer-motion"; const SLIDES = [ - { src: "/images/splash/left.png", alt: "ADE — worktrees and missions" }, + { src: "/images/splash/left.png", alt: "ADE — worktrees and automations" }, { src: "/images/splash/middle.png", alt: "ADE — main workspace" }, { src: "/images/splash/right.png", alt: "ADE — editor and tools" }, ] as const; diff --git a/automations/executors.mdx b/automations/executors.mdx index 2f172e203..0ec6394d9 100644 --- a/automations/executors.mdx +++ b/automations/executors.mdx @@ -1,6 +1,6 @@ --- title: "Execution surfaces" -description: "Pick between an automation-only agent session, a Mission, or a built-in task for each automation rule." +description: "Pick between an automation-only agent session, a Worker run, or a built-in task for each automation rule." icon: "robot" --- @@ -11,11 +11,11 @@ Every automation rule answers the same question twice: what triggers it, and wha | Surface | Best for | Cost | |---|---|---| | `agent-session` | Lightweight checks, short summaries, a single deterministic output | One short agent thread | -| `mission` | Multi-step code changes, long workflows that need reviewable plans | A full mission run | +| `worker run` | Multi-step code changes, long workflows that need reviewable plans | A delegated worker run | | `built-in` | Deterministic ADE operations — tests, conflict prediction, status sync | No agent; no tokens | -**Mission execution is paused in production builds.** In packaged releases the rule editor and the Templates tab still display Mission as an option so teams can see how they would wire it, but selecting Mission is disabled with a "Coming soon" chip and the Save button is blocked. Dev and internal builds run mission automations today. When you plan an automation that needs a mission surface, use Chat or a built-in task as a stand-in until Missions ships. +**Worker run execution is paused in production builds.** In packaged releases the rule editor and the Templates tab still display Worker run as an option so teams can see how they would wire it, but selecting Worker run is disabled with a "Coming soon" chip and the Save button is blocked. Dev and internal builds run worker run automations today. When you plan an automation that needs a worker run surface, use Chat or a built-in task as a stand-in until Workers ships. --- @@ -34,23 +34,23 @@ Every automation rule answers the same question twice: what triggers it, and wha Agent-session runs are recorded as **automation-only chat threads** visible in **Automations → History**. - - A full mission run for work that needs a reviewable plan, multiple workers, or durable checkpoints. + + A delegated worker run for work that needs a reviewable plan, multiple workers, or durable checkpoints. - Planner, approval gate, execution, and validation phases - Retry, intervention, and closeout handling - Worker lifecycle with lane affinity - Artifacts, step events, and the full intervention feed - Mission outputs are visible in **Missions** — History rows for mission-executed automation runs link directly into the mission detail view. + Worker run outputs are visible in **Workers** — History rows for worker run-executed automation runs link directly into the worker run detail view. - In packaged builds the option appears but is disabled until Missions ships. + In packaged builds the option appears but is disabled until Workers ships. A structured ADE-native task for short, deterministic work that doesn't need a conversation. - - No full chat or mission thread + - No full chat or worker run thread - Strongly typed inputs and outputs - Useful for running test suites, predicting conflicts, and other maintenance sweeps @@ -65,7 +65,7 @@ Every automation rule answers the same question twice: what triggers it, and wha 1. Pick a **time-based** (`schedule`) or **action-based** (git, file, lane, webhook, Linear, manual) trigger. 2. Pick an execution surface: - `agent-session` for a quick AI output - - `mission` for a complex or high-stakes workflow (dev builds only today) + - `worker run` for a complex or high-stakes workflow (dev builds only today) - `built-in` for a known ADE operation 3. For built-in rules, pick the **target lane** or leave it on "Auto-select from the trigger or primary lane." 4. Set a tool palette and context policy. @@ -81,4 +81,4 @@ Agent-session rules can still route their outputs to artifacts, PR comments, or - **PR reviewer** — trigger `git.pr_opened`, execution `agent-session`, output an automation thread plus an optional PR comment. - **Nightly test sweep** — trigger `schedule` (`0 8 * * 1-5`), execution `built-in` (run-tests) targeted at the primary lane. No tokens, just a test report artifact. - **Cache cleanup** — trigger `github-webhook`, execution `built-in`, internal usage summary with a status event. -- **Release triage** — trigger `linear.issue_created`, execution `mission` (dev builds) or a multi-step `agent-session` as a fallback while Mission is gated. +- **Release triage** — trigger `linear.issue_created`, execution `worker run` (dev builds) or a multi-step `agent-session` as a fallback while Worker run is gated. diff --git a/automations/guardrails.mdx b/automations/guardrails.mdx index be4f21737..c0f9a09dc 100644 --- a/automations/guardrails.mdx +++ b/automations/guardrails.mdx @@ -1,6 +1,6 @@ --- title: "Guardrails & Security" -description: "Secure your automations with tool palette restrictions, budget caps, and preflight controls. Covers agent-session, mission, and built-in-task execution." +description: "Secure your automations with tool palette restrictions, budget caps, and preflight controls. Covers agent-session, worker run, and built-in-task execution." icon: "shield-check" --- @@ -13,7 +13,7 @@ Every automation can restrict its ADE tool surface with a **tool palette**. This | `read-only` | Read files, read git history, read context packs | Review, summarization, analysis | | `write-files` | `read-only` + write and create files | Code/document edits, lightweight maintenance | | `full-git` | `write-files` + all git operations | Commit/branch automation, PR prep | -| `full-ade` | All ADE tools including mission/Linear/gh actions | Complex orchestration automation | +| `full-ade` | All ADE tools including worker run/Linear/gh actions | Complex orchestration automation | | Custom | Explicitly listed tools | Precise scope for a specific workflow | @@ -51,7 +51,7 @@ Control how much execution state an automation carries between runs. Automation budgets are enforced from shared settings in **Settings > Usage** and apply across: - Background automations -- Missions +- Workers - Chat and terminal automation helpers - Linear and GitHub maintenance jobs diff --git a/automations/overview.mdx b/automations/overview.mdx index 4d7023883..8984bd34b 100644 --- a/automations/overview.mdx +++ b/automations/overview.mdx @@ -1,6 +1,6 @@ --- title: "Automations Overview" -description: "Programmable rules that run background work by time or action. Each automation can launch an agent session, a mission, or a built-in task from a single surface." +description: "Programmable rules that run background work by time or action. Each automation can launch an agent session, a worker run, or a built-in task from a single surface." icon: "bolt" --- ## Overview @@ -8,7 +8,7 @@ icon: "bolt" Automations is ADE's background execution layer. Each automation is a **Trigger → Execution** rule. When a trigger fires, ADE executes through one of three surfaces: - **agent-session** — an automation-only AI chat thread with scoped tools. -- **mission** — a full mission run with phases, workers, and intervention support. (Paused in packaged builds until Missions ships; see the Execution Surfaces page.) +- **worker run** — a delegated worker run with phases, workers, and intervention support. (Paused in packaged builds until Workers ships; see the Execution Surfaces page.) - **built-in** — an ADE-native operation (tests, conflict prediction, status sync) with no chat session. Can target a specific lane or auto-select from the trigger. The trigger can come from a schedule (`schedule`) or an action event (`webhook`, `github.webhook`, `git.push`, `git.commit`, manual run). @@ -18,7 +18,7 @@ The trigger can come from a schedule (`schedule`) or an action event (`webhook`, Time-based (cron) and action-based triggers fire automatically based on events from git, files, webhooks, and user actions. - Choose between Agent Session, Mission, and Built-In Task based on autonomy and audit needs. + Choose between Agent Session, Worker run, and Built-In Task based on autonomy and audit needs. Budget caps, scoped tools, dry-run mode, and verification gates keep automation outcomes controlled. @@ -56,7 +56,7 @@ The trigger can come from a schedule (`schedule`) or an action event (`webhook`, - Learn about agent-session, mission, and built-in execution and how to pick the right one. + Learn about agent-session, worker run, and built-in execution and how to pick the right one. Browse built-in automation templates and configure output routing. diff --git a/automations/templates.mdx b/automations/templates.mdx index 3604a175d..54815df04 100644 --- a/automations/templates.mdx +++ b/automations/templates.mdx @@ -11,7 +11,7 @@ Templates are pre-configured automation rules you can activate with a click. Eac Navigate to **Automations → Templates** to browse them. -Templates whose execution surface is a Mission show a **"Coming soon"** chip in packaged builds and the Use Template button reads "Coming Soon" with a tooltip explaining why. The card is still visible so you can see the shape of the rule and plan around it. +Templates whose execution surface is a Worker run show a **"Coming soon"** chip in packaged builds and the Use Template button reads "Coming Soon" with a tooltip explaining why. The card is still visible so you can see the shape of the rule and plan around it. ### Built-in templates @@ -21,7 +21,7 @@ Templates whose execution surface is a Mission show a **"Coming soon"** chip in **Trigger:** `schedule` — weekdays at 9am. **Execution:** Agent session. - Summarizes repo activity since the last weekday brief — commits, PRs, missions, and risk signals — in an automation-only chat thread. Pulls repo state and procedures as context. + Summarizes repo activity since the last weekday brief — commits, PRs, worker runs, and risk signals — in an automation-only chat thread. Pulls repo state and procedures as context. **Trigger:** `git.pr_opened` on `main`. @@ -29,17 +29,17 @@ Templates whose execution surface is a Mission show a **"Coming soon"** chip in Reviews every new PR for risky changes, missing tests, and release impact. Posts findings citing specific files and checks, with high thinking level for thorough analysis. - + **Trigger:** `linear.issue_created` (team: ENG). - **Execution:** Mission (disabled in packaged builds). + **Execution:** Worker run (disabled in packaged builds). - Launches a triage mission on new Linear issues — owner, severity, next action — using mission-level tooling. In production builds the card shows "Coming soon" until Missions ships. + Launches a triage worker run on new Linear issues — owner, severity, next action — using worker run-level tooling. In production builds the card shows "Coming soon" until Workers ships. **Trigger:** `schedule` — weekdays at 2am. **Execution:** Built-in (`run-tests`). - Runs your configured test suite without starting a chat or mission. Results are saved as automation artifacts. Requires a test suite ID in project config. + Runs your configured test suite without starting a chat or worker run. Results are saved as automation artifacts. Requires a test suite ID in project config. **Trigger:** `git.push` on `main`. @@ -58,7 +58,7 @@ Templates whose execution surface is a Mission show a **"Coming soon"** chip in Open **Automations → Templates**. Each card shows trigger, execution kind, and a short summary. - Click **Use Template**. ADE creates a new rule pre-filled with the template's configuration and opens it in the editor. (For a mission-based template in a packaged build the button reads **Coming Soon** and is disabled.) + Click **Use Template**. ADE creates a new rule pre-filled with the template's configuration and opens it in the editor. (For a worker run-based template in a packaged build the button reads **Coming Soon** and is disabled.) Adjust trigger conditions, prompt, model, guardrails, and outputs. Built-in rules expose a **Target lane** dropdown at the top of the action editor — leave it on the default to auto-select from the trigger or primary lane, or pick a specific lane. @@ -82,11 +82,11 @@ Every template produces a rule with these fields: |---|---| | Name | Identifier shown in the Automations list | | Trigger | Event or schedule that fires the rule | -| Execution kind | `agent-session`, `mission`, or `built-in` | +| Execution kind | `agent-session`, `worker run`, or `built-in` | | Executor mode | Always `automation-bot` for templates | -| Prompt | Instruction given to the agent (for agent-session and mission kinds) | +| Prompt | Instruction given to the agent (for agent-session and worker run kinds) | | Model config | Model ID and thinking level | -| Tool palette | ADE CLI domains the agent can use (repo, git, github, linear, mission, tests) | +| Tool palette | ADE CLI domains the agent can use (repo, git, github, linear, worker run, tests) | | Context sources | What context is seeded (repo state, procedures, lane packs) | | Guardrails | Max duration, confidence threshold, review gates | | Outputs | Where results land (see below) | @@ -123,7 +123,7 @@ Automations can produce several output types. Configure routing in the rule's se ## Viewing automation runs -The **History** tab lists every run with its status, trigger, duration, and cost. Run rows now support direct navigation — if a run executed as a mission, the detail panel includes a link that opens the mission in the Missions view. +The **History** tab lists every run with its status, trigger, duration, and cost. Run rows now support direct navigation — if a run executed as a worker run, the detail panel includes a link that opens the worker run in the Workers view. Rows are filterable by rule, status, and time window. Errors are surfaced inline; you don't have to open the detail panel to see that something failed. diff --git a/changelog/v1.0.11.mdx b/changelog/v1.0.11.mdx index 110bd8ec5..3723b7acf 100644 --- a/changelog/v1.0.11.mdx +++ b/changelog/v1.0.11.mdx @@ -32,7 +32,7 @@ Largest release to date — 102 commits across 12 merged PRs. - **Rebase flow** — Runtime lifecycle cleanup on worktree switch, `getRebaseNeed` throws on lookup errors (instead of returning null), rebase attention states preserved across emits - **Lane import logic** — Detects and reuses existing local branches, only creates tracking branches when needed, attempts parent lane detection via merge-base - **PR service** — Expanded with retarget error tracking, auto-rebase land support, and convergence state management -- **Missions & orchestrator** — Improved lifecycle management, convergence pipeline integration in the phase engine +- **Runs & orchestrator** — Improved lifecycle management, convergence pipeline integration in the phase engine - **Local model discovery** — Cached provider inspection, `loadAttemptId` to ignore stale results, hardened local runtime path resolution - **Model selector** — Completely reworked with provider grouping and availability indicators - **Marketing site** — New `ProductShowcase` component with real screenshot PNGs for all feature areas diff --git a/changelog/v1.0.12.mdx b/changelog/v1.0.12.mdx index 80b31a180..f0f2e42c3 100644 --- a/changelog/v1.0.12.mdx +++ b/changelog/v1.0.12.mdx @@ -8,7 +8,7 @@ description: "Release notes for ADE v1.0.12 — April 2, 2026" - **Inline AskUserQuestion UI** — Agent questions now render inline in the chat transcript with option buttons instead of a screen-blocking modal overlay. Supports both single-select and multi-select answers with Markdown preview - **Multi-select agent questions** — `AgentQuestionModal` supports toggling multiple predefined options per question, with a Markdown/HTML preview pane via `ReactMarkdown` + `rehype-sanitize` - **Claude tool-use lifecycle tracking** — Per-invocation tracking using `toolUseID` with `tool_use_start` and `tool_use_complete` events driving per-tool status indicators in the work log -- **Standalone chat `ask_user` ADE CLI tool** — The `ask_user` tool no longer requires a `missionId`, making it usable in standalone chat sessions with structured `questions` array parameter (up to 3 questions with options, multi-select, preview content, freeform toggle) +- **Standalone chat `ask_user` ADE CLI tool** — The `ask_user` tool no longer requires a `runId`, making it usable in standalone chat sessions with structured `questions` array parameter (up to 3 questions with options, multi-select, preview content, freeform toggle) - **Packed session grid** — Resizable tile layout for the Work view with per-session column/row spans, drag-handle resizing on all edges and corners, and a bin-packing algorithm for compact arrangement - **Turn recap** — Summary row at the end of each turn showing tool invocation counts and task progress - **ADE CLI initialize probe** — Full ADE CLI initialize JSON-RPC round-trip check in the runtime smoke test, catching regressions where the proxy starts but fails the initialize handshake diff --git a/changelog/v1.0.14.mdx b/changelog/v1.0.14.mdx index 185990b64..3245d3af9 100644 --- a/changelog/v1.0.14.mdx +++ b/changelog/v1.0.14.mdx @@ -75,8 +75,8 @@ New `openCodeServerManager.ts` (488 lines) providing managed OpenCode server lif - New tool capabilities and enhanced request routing -- `parseInitializeIdentity` now validates `identity.missionId` against the server-authorized run context, preventing cross-mission request injection -- `ask_user` tool routing to mission interventions with proper orchestrator pause semantics +- `parseInitializeIdentity` now validates `identity.runId` against the server-authorized run context, preventing cross-run request injection +- `ask_user` tool routing to run interventions with proper orchestrator pause semantics - Test suite restructured with corrected identity/environment alignment --- @@ -101,7 +101,7 @@ New `openCodeServerManager.ts` (488 lines) providing managed OpenCode server lif ## Housekeeping -- Removed stale test files: `CtoPage.test.tsx`, `CommitTimeline.test.tsx`, `missionHelpers.test.ts`, `PrAiResolverPanel.test.tsx` +- Removed stale test files: `CtoPage.test.tsx`, `CommitTimeline.test.tsx`, `runHelpers.test.ts`, `PrAiResolverPanel.test.tsx` - Removed 10 brittle `AgentChatMessageList` render tests broken by the work log redesign - Added global `lottie-react` jsdom stub in `vitest.workspace.ts` to prevent canvas crashes in renderer tests - New `opencode-ai-sdk.d.ts` type declarations diff --git a/changelog/v1.0.15.mdx b/changelog/v1.0.15.mdx index d1e65ba8c..3314568f8 100644 --- a/changelog/v1.0.15.mdx +++ b/changelog/v1.0.15.mdx @@ -77,7 +77,7 @@ PTY session reattach logic was expanded with: ### Coordinator tool hiding -When a chat session is opened from the standalone ADE CLI entry point (not within a mission), all coordinator-specific tools (task delegation, worker management, mission lifecycle) are now hidden from the tool palette. This prevents confusing tool-not-found errors when users invoke tools that require a mission context. +When a chat session is opened from the standalone ADE CLI entry point (not within a run), all coordinator-specific tools (task delegation, worker management, run lifecycle) are now hidden from the tool palette. This prevents confusing tool-not-found errors when users invoke tools that require a run context. The session type is now determined by a server-validated flag rather than a caller-controlled field, closing a loophole where crafted requests could expose coordinator tools in standalone sessions. diff --git a/changelog/v1.0.18.mdx b/changelog/v1.0.18.mdx index 0ca900f14..4dd5df252 100644 --- a/changelog/v1.0.18.mdx +++ b/changelog/v1.0.18.mdx @@ -3,7 +3,7 @@ title: "v1.0.18" description: "Release notes for ADE v1.0.18 — April 14, 2026" --- -A consolidation release: the Lanes, Missions, and Run surfaces shed several legacy panels in favour of fewer, denser screens; the chat transcript adds an in-line file-changes panel; a new Quick Run menu and stacked-run tabs land on the Run page; and the renderer picks up batched user-preference persistence, a visibility-aware watchdog, and a unified warmup timer. +A consolidation release: the Lanes, Runs, and Run surfaces shed several legacy panels in favour of fewer, denser screens; the chat transcript adds an in-line file-changes panel; a new Quick Run menu and stacked-run tabs land on the Run page; and the renderer picks up batched user-preference persistence, a visibility-aware watchdog, and a unified warmup timer. --- @@ -18,11 +18,11 @@ A round of legacy lane panels were retired in favor of the consolidated lane wor - `LanePrPanel` (and its tests) and the inline PR terminal (`InlineTerminal`) are removed; PR work is fully driven from the PRs page and the Path-to-Merge tab. - The `LaunchPanel` terminal launcher is gone — the same actions live in the new Quick Run menu and the existing Work surface. -### Missions UI consolidation +### Runs UI consolidation -The bespoke mission micro-pages were absorbed into the main Missions experience: +The bespoke run micro-pages were absorbed into the main Runs experience: -- `MissionControlPage`, `MissionPhaseBadge`, `UsageDashboard`, and `WorkerTranscriptPane` are removed — their content now renders inside `MissionsPage` and the associated panels there. +- `RunControlPage`, `RunPhaseBadge`, `UsageDashboard`, and `WorkerTranscriptPane` are removed — their content now renders inside `RunsPage` and the associated panels there. - The `WorkerDetailSlideOut` slideout for individual worker inspection was retired; the same details surface inside the worker timeline. ### Run page replaces Project Home @@ -149,7 +149,7 @@ The on-site docs at [ade-app.dev/docs](https://www.ade-app.dev/docs) received a A new `## Applied` section was appended to `docs/OPTIMIZATION_OPPORTUNITIES.md` describing the renderer optimizations that landed this release. -The internal architecture notes were reorganized: legacy `docs/architecture/*` and `docs/final-plan/*` trees were removed in favor of a new `docs/features/*` hierarchy with per-feature READMEs (lanes, missions, chat, cto, computer-use, terminals-and-sessions, sync-and-multi-device, automations, conflicts, files-and-editor, history, linear-integration, onboarding-and-settings, pull-requests, workspace-graph, agents, context-packs, project-home). The overall docs layout is captured in a top-level `docs/ARCHITECTURE.md` and `docs/README.md`. +The internal architecture notes were reorganized: legacy `docs/architecture/*` and `docs/final-plan/*` trees were removed in favor of a new `docs/features/*` hierarchy with per-feature READMEs (lanes, runs, chat, cto, computer-use, terminals-and-sessions, sync-and-multi-device, automations, conflicts, files-and-editor, history, linear-integration, onboarding-and-settings, pull-requests, workspace-graph, agents, context-packs, project-home). The overall docs layout is captured in a top-level `docs/ARCHITECTURE.md` and `docs/README.md`. --- diff --git a/changelog/v1.0.19.mdx b/changelog/v1.0.19.mdx index 9cdc93f18..5c7ab900c 100644 --- a/changelog/v1.0.19.mdx +++ b/changelog/v1.0.19.mdx @@ -9,7 +9,7 @@ New AI backends, iOS push notifications, smarter PR and rebase tooling, and a di ## Mobile push notifications (APNS) -ADE can now send push notifications to your iPhone when agents complete tasks or need your input. Notifications fire for mission complete, mission failed, and intervention required events — so you no longer have to keep the desktop app visible to stay aware of long-running work. +ADE can now send push notifications to your iPhone when agents complete tasks or need your input. Notifications fire for run complete, run failed, and intervention required events — so you no longer have to keep the desktop app visible to stay aware of long-running work. Setup takes about 30 seconds: open **Settings → Notifications → Mobile Push**, scan the QR code with the ADE iOS app, and choose which events trigger a push. Multiple devices can be registered. @@ -27,7 +27,7 @@ See [Cursor integration](/ai-tools/cursor) for setup details. ## OpenCode runtime integration -ADE now supports [OpenCode](https://opencode.ai) as a managed AI backend. ADE downloads and manages the OpenCode binary, starts it as a local server process, and routes chat and mission requests through its OpenAI-compatible endpoint. +ADE now supports [OpenCode](https://opencode.ai) as a managed AI backend. ADE downloads and manages the OpenCode binary, starts it as a local server process, and routes chat and run requests through its OpenAI-compatible endpoint. OpenCode supports three backend modes: Anthropic API key, OpenRouter, or a fully local model via Ollama. This makes it useful when you want a local-first agent without a separate Claude or Cursor subscription. diff --git a/changelog/v1.1.0.mdx b/changelog/v1.1.0.mdx index 97e1764e1..6324fa97b 100644 --- a/changelog/v1.1.0.mdx +++ b/changelog/v1.1.0.mdx @@ -58,13 +58,13 @@ What was removed: - **Passive `proofObserver`** (~460 lines) and its tests — no more background screenshotting. - **`controlPlane.ts`, `localComputerUse.ts` backend-style arbitration** — Ghost OS, agent-browser, and ADE Local are no longer separate backends the app manages. -- **Per-phase coverage math** — missions don't block on "you need one screenshot and one browser trace". +- **Per-phase coverage math** — runs don't block on "you need one screenshot and one browser trace". - **`CU Off` / `CU Auto` / `CU On` / `Fallback` / `Proof` chips**, the `ComputerUseSection` of Settings, and the `buildComputerUseDirective` chat prompt preamble. What stays — renamed and shrunk: - **`proofArtifactBrokerService`** (formerly `computerUseArtifactBrokerService`) still persists screenshots, owner snapshots, and review state to SQLite. -- **`MissionProofPanel`** and **`ChatProofPanel`** (formerly the computer-use panels) render the artifact drawer in missions and chat. +- **`RunProofPanel`** and **`ChatProofPanel`** (formerly the computer-use panels) render the artifact drawer in runs and chat. - **`proofControlPlane.ts`** is a tiny helper that lists artifact kinds and feeds the review surface — no backend arbitration, no fallback logic. - **Agent chat directive**: a single sentence telling the agent to run `ade proof capture` when it reaches a checkpoint worth showing. That replaces the multi-paragraph policy prompt. @@ -124,7 +124,7 @@ The iOS `SyncService` grew from ~300 lines of logic to ~900 to support tailnet d ### Work + Settings polish -- **`WorkChatSessionView+Timeline`** consolidates the live/static branches so pending structured questions, mission briefings, and action rows render consistently once a session ends. +- **`WorkChatSessionView+Timeline`** consolidates the live/static branches so pending structured questions, run briefings, and action rows render consistently once a session ends. - **`SettingsPairingSection`** surfaces "Saved Tailscale route" when no live hosts are discovered but a Tailscale-addressed host is on file, and rewrites the discover sheet to show the saved host alongside new ones instead of substituting them. - **`SettingsConnectionHeader`** now meets the 44pt minimum hit target on the connected chip, and the connection status hovers around the gear dot in the nav bar on every root screen. - **App icon parity.** The iOS app icon now uses a full-bleed, opaque export of the centered ADE artwork, so the mark matches the macOS logo without a nested desktop badge and satisfies App Store upload checks. @@ -137,7 +137,7 @@ The iOS `SyncService` grew from ~300 lines of logic to ~900 to support tailnet d - **PrDetailPane**: the issue-resolver availability calculation now folds in PR comments alongside checks and review threads, so "Send to agent" actually appears when the relevant signals come from review comments rather than formal review threads. - **PRsPage + GitHubTab**: small wiring tweaks to align with the updated issue inventory service and the new `data-tour` anchors. - **PrIssueResolverModal**: shared `isActionableIssueComment`, `isPrCheckActionableFailure`, and `isPrCheckPassing` helpers landed in `shared/prIssueResolution.ts` so the issue resolver, convergence service, and workflow tools all agree on what counts as actionable. -- **Provider permission merge.** Project-level and mission-level AI permission policies now merge before launching managed sessions, so Codex receives the intended sandbox, approval, edit, and `config-toml` settings while unsupported Claude config is rejected early. +- **Provider permission merge.** Project-level and run-level AI permission policies now merge before launching managed sessions, so Codex receives the intended sandbox, approval, edit, and `config-toml` settings while unsupported Claude config is rejected early. - **Automation lane targeting.** Automation runs now prefer explicit target lanes, render prompts with the same lane the agent will actually use, and avoid misleading lane labels during follow-up review cycles. - **Rebase and review fixes.** Auto-rebase dismissal keeps its head-SHA guard, rebase banner errors remain visible, and redundant rebase suggestion state was removed so review comments do not re-open stale UI paths. @@ -145,9 +145,9 @@ The iOS `SyncService` grew from ~300 lines of logic to ~900 to support tailnet d ## Platform plumbing -- **`dialogBus`** — a new tiny pub/sub with stable dialog ids (`lanes.create`, `missions.create`, …). Dialogs subscribe by id; the tour engine drives them open/close without prop drilling. Full test coverage. +- **`dialogBus`** — a new tiny pub/sub with stable dialog ids (`lanes.create`, `runs.create`, …). Dialogs subscribe by id; the tour engine drives them open/close without prop drilling. Full test coverage. - **`waitForTarget`** — MutationObserver-based selector waiter with a polling fallback, aborts via `AbortSignal`, and a 10 s default timeout. -- **Workflow tools** — the `ade_complete_mission`, `ade_complete_pr_convergence_round`, and related tools now consume `proofArtifactBrokerService` directly and share issue-resolution helpers with the renderer. +- **Workflow tools** — the `ade_complete_run`, `ade_complete_pr_convergence_round`, and related tools now consume `proofArtifactBrokerService` directly and share issue-resolution helpers with the renderer. - **CTO operator tools** — the `listComputerUseArtifacts` / `getArtifactPreview` tools are renamed to `listProofArtifacts` / `getProofArtifactPreview` with the same schema. - **IPC cleanup** — `registerIpc.ts` grew handlers for `sync.refreshDiscovery`, `onboarding.tutorial.*` (start, advance, complete, prompt state), and the proof artifact APIs; legacy computer-use IPC routes are gone. The `preload` surface was updated accordingly. - **ade-cli RPC rewrite** — `adeRpcServer.ts` shed ~400 lines as redundant legacy endpoints fell off; `cli.ts` grew to match the new proof command set. diff --git a/changelog/v1.1.12.mdx b/changelog/v1.1.12.mdx index e52891016..dd66cca58 100644 --- a/changelog/v1.1.12.mdx +++ b/changelog/v1.1.12.mdx @@ -19,10 +19,10 @@ v1.1.12 broadens CLI agent support (cursor-agent, droid, opencode), adds Codex F
- The built-in browser learns to spoof its User-Agent, the PTY exports ADE chat/mission/lane context, and the chat composer/work-log get a localhost-link shortcut. + The built-in browser learns to spoof its User-Agent, the PTY exports ADE chat/run/lane context, and the chat composer/work-log get a localhost-link shortcut. - **Built-in browser UA spoof + open-panel.** New UA-spoofing surface and `openPanel` IPC, with redacted URL params in IPC logs so query strings don't leak to the journal. `BuiltInBrowserService` gains tests and a sharper screenshot/CDP surface. - - **PTY ADE context env.** `ptyService` injects `ADE_CHAT`, `ADE_MISSION`, and `ADE_LANE` into spawned processes so CLIs can read the active chat/mission/lane from env without IPC. `terminalSessionSignals` validates resume candidates via `parseTrackedCliResumeCommand` before re-attaching. + - **PTY ADE context env.** `ptyService` injects `ADE_CHAT`, `ADE_RUN`, and `ADE_LANE` into spawned processes so CLIs can read the active chat/run/lane from env without IPC. `terminalSessionSignals` validates resume candidates via `parseTrackedCliResumeCommand` before re-attaching. - **Localhost-link shortcuts.** `ChatWorkLogBlock` recognizes localhost URLs in chat work-log output and opens them in the built-in browser; `chatTranscriptRows` picks up the same routing for transcript output. - **ChatTerminalDrawer state.** `setActiveTabId` is pulled out of the `setTabs` updater so tab focus no longer races with tab list updates. diff --git a/changelog/v1.1.3.mdx b/changelog/v1.1.3.mdx index 7455a2473..630b34b0d 100644 --- a/changelog/v1.1.3.mdx +++ b/changelog/v1.1.3.mdx @@ -9,7 +9,7 @@ v1.1.3 is a feature-weight release: automations get a ground-up rewrite, the reb ## Automations -The automations page is now a proper rules engine. Triggers are modelled as `github.*` / `linear.*` / `schedule.*` / `local-git.*` families; actions can be ADE Actions, agent sessions, or launch-mission runs; and each rule has its own per-rule history panel instead of a shared feed. +The automations page is now a proper rules engine. Triggers are modelled as `github.*` / `linear.*` / `schedule.*` / `local-git.*` families; actions can be ADE Actions, agent sessions, or launch-run runs; and each rule has its own per-rule history panel instead of a shared feed. - **GitHub polling triggers.** A new `githubPollingService` watches issues, PRs, comments, and reviews on every configured repo. Comment cursors use `created_at#id` for stable ordering, and review cursors use `submitted_at#review.id` — two reviews submitted in the same ISO second no longer race each other off the cursor and get dropped. - **ADE Actions registry.** `apps/desktop/src/main/services/adeActions/registry.ts` is the single source of truth for what an automation is allowed to invoke. Domains are exported as `ADE_ACTION_DOMAIN_NAMES`, and `adeRpcServer` derives its `list_ade_actions` / `run_ade_action` enums from that list — we no longer advertise ~25 domains that had no registry backing, and the actually-implemented `automations` and `issue` domains are now visible. diff --git a/changelog/v1.1.6.mdx b/changelog/v1.1.6.mdx index b32b7ec14..e9e85632d 100644 --- a/changelog/v1.1.6.mdx +++ b/changelog/v1.1.6.mdx @@ -23,10 +23,10 @@ v1.1.6 makes automation rules first-class: per-action targeting and overrides, a After the recent feature ripouts, the test tree was carrying a lot of dead and fragmented files. This release collapses them. -- **Orchestrator tests: 17 → 12 suites.** `orchestratorAdapters.test.ts` absorbs `baseOrchestratorAdapter` + `providerOrchestratorAdapter` + `permissionMapping` + `modelConfigResolver`. `mission.test.ts` absorbs `missionLifecycle` + `missionBudgetService` + `missionStateDoc`. `orchestratorPlanning.test.ts` absorbs `orchestratorContext` + `delegationContracts`. All 463 cases preserved. +- **Orchestrator tests: 17 → 12 suites.** `orchestratorAdapters.test.ts` absorbs `baseOrchestratorAdapter` + `providerOrchestratorAdapter` + `permissionMapping` + `modelConfigResolver`. `run.test.ts` absorbs `runLifecycle` + `runBudgetService` + `runStateDoc`. `orchestratorPlanning.test.ts` absorbs `orchestratorContext` + `delegationContracts`. All 463 cases preserved. - **CTO tests: 26 → 7 suites.** Linear OAuth/credential/client into `linearAuth`; sync/dispatcher/outbound/template/workflow-file into `linearSync`; intake/ingress/routing/closeout into `linearIntake`; worker lifecycle tests into `ctoWorkerLifecycle`; pipelineHelpers + pipelineLabels into `pipeline`; settings panel + Linear sync panel + session view state into `ctoUi`. 226 cases preserved verbatim, scoped per-source via outer `describe(...)` blocks. - **PR-service tests: 10 → 5 feature suites.** `prMergeQueue` (queueLandingService + integrationPlanning + integrationValidation), `prRebase` (prRebaseResolver + resolverUtils via `vi.importActual`), `prAsync` (prPollingService + prSummaryService), `prIssueResolution` (issueInventoryService + prIssueResolver). 180 cases preserved. -- **Dead tests removed.** Orphaned tests in `orchestrator/`, `prs/`, `missions/`, and a handful of others where the source files no longer exist. A no-op `expect(true).toBe(true)` in `usageTrackingService.test.ts` is rewritten to a real `not.toThrow()` check. +- **Dead tests removed.** Orphaned tests in `orchestrator/`, `prs/`, `runs/`, and a handful of others where the source files no longer exist. A no-op `expect(true).toBe(true)` in `usageTrackingService.test.ts` is rewritten to a real `not.toThrow()` check. --- diff --git a/changelog/v1.1.7.mdx b/changelog/v1.1.7.mdx index 167b79e6f..c86896cba 100644 --- a/changelog/v1.1.7.mdx +++ b/changelog/v1.1.7.mdx @@ -27,7 +27,7 @@ v1.1.7 is one of the largest ADE releases to date — 19 commits, 415 files, ~+4 - **Droid ACP runtime.** `droidAcpPool` spawns `droid exec --output-format acp` and pools JSON-RPC connections by lane. Authentication runs through Droid's CLI plus `FACTORY_API_KEY`. Dynamic `droid/*` model descriptors are discovered at runtime with provider-side defaults. - **Shared ACP host client.** `acpHostClient` factors the JSON-RPC handshake, lifecycle probing, and steer-queue plumbing into one well-tested client for Droid and any future ACP provider. - - **End-to-end chat wiring.** `DroidRuntime` is plumbed through `agentChatService` for turn execution, steer queue, interrupts, permission prompts, and persistence. AI status, model picker grouping, providers settings, and missions permissions all learn the new `droid` provider. + - **End-to-end chat wiring.** `DroidRuntime` is plumbed through `agentChatService` for turn execution, steer queue, interrupts, permission prompts, and persistence. AI status, model picker grouping, providers settings, and runs permissions all learn the new `droid` provider. - **Pool reliability.** Acquires are now serialized per pool key with a shared pending-init lock, so two simultaneous chat opens cannot double-spawn. Stale pool entries are evicted on child `close`/`error`. Init/handshake failures kill the process and log the stderr tail. ACP terminal cwds resolve within the lane root, and `waitForTerminalExit` is capped with a SIGKILL escalation + close wait to avoid stuck terminals.
diff --git a/chat/capabilities.mdx b/chat/capabilities.mdx index 3c39d4293..4f908fea3 100644 --- a/chat/capabilities.mdx +++ b/chat/capabilities.mdx @@ -1,6 +1,6 @@ --- title: "Chat Capabilities" -description: "What the chat agent can do — file operations, terminal commands, git, PRs, Linear integration, mission spawning, repo inspection, and ADE action tools." +description: "What the chat agent can do — file operations, terminal commands, git, PRs, Linear integration, worker run spawning, repo inspection, and ADE action tools." icon: "toolbox" --- @@ -24,8 +24,8 @@ The chat agent has access to a broad tool palette. The exact tools available dep Create and update Linear issues, link issues to branches, and attach artifacts to issues. - - Escalate the current task into a new Mission, carrying over conversation context as the planner's seed. + + Escalate the current task into a new Worker run, carrying over conversation context as the planner's seed. Inspect repository docs, source files, file maps, open PRs, recent activity, and human work digests. @@ -74,23 +74,23 @@ The most common workflow is: make changes in chat → have the agent create a PR --- -## Spawning a Mission from Chat +## Spawning a Worker run from Chat -When a task is too large for a single chat session, escalate to a Mission without losing context. +When a task is too large for a single chat session, escalate to a Worker run without losing context. Escalate when the task needs changes across more than 3–4 unrelated files, parallel workers, a validation phase, or a formal audit trail. - - Type "start a mission for this" or "this should be a mission." ADE opens the Mission creation dialog, pre-filled with: + + Type "start a worker run for this" or "this should be a worker run." ADE opens the Worker run creation dialog, pre-filled with: - A title derived from the current conversation - A description assembled from your messages in the session - The current lane pre-selected - Adjust the mission config as needed (add lanes, set budget cap, configure permissions), then click **Start Mission**. The mission opens in the Mission detail view. The chat session remains in History for reference. + Adjust the worker run config as needed (add lanes, set budget cap, configure permissions), then click **Start Worker run**. The worker run opens in the Worker run detail view. The chat session remains in History for reference. diff --git a/chat/context.mdx b/chat/context.mdx index c4ba63adf..bfbbfd2ca 100644 --- a/chat/context.mdx +++ b/chat/context.mdx @@ -56,10 +56,10 @@ At the end of a session (or mid-session when the agent produces notable outputs) - Promotes the artifact into the lane's persistent context. Future chat sessions and missions will have access to it via context injection. + Promotes the artifact into the lane's persistent context. Future chat sessions and worker runs will have access to it via context injection. - - Links the artifact to an existing Mission, opens it for attachment to a PR description, or creates a Linear issue attachment. + + Links the artifact to an existing Worker run, opens it for attachment to a PR description, or creates a Linear issue attachment. Saves the artifact to your local filesystem (useful for screenshots and browser traces). diff --git a/chat/overview.mdx b/chat/overview.mdx index 64bb7dfe8..18226b636 100644 --- a/chat/overview.mdx +++ b/chat/overview.mdx @@ -1,11 +1,11 @@ --- title: "Agent Chat Overview" -description: "Conversational AI with real project access — read files, run commands, create PRs, spawn missions, and capture proof artifacts, all from a single chat interface." +description: "Conversational AI with real project access — read files, run commands, create PRs, spawn worker runs, and capture proof artifacts, all from a single chat interface." icon: "comments" --- ## What is Agent Chat? -**Agent Chat** is ADE's conversational interface for AI agents wired into your development environment. The agent reads and writes real files, runs terminal commands, creates branches and PRs, queries your context pack, and can escalate to a Mission when the task outgrows a single session. Every chat session is recorded with a full transcript, tool call log, and timing data. +**Agent Chat** is ADE's conversational interface for AI agents wired into your development environment. The agent reads and writes real files, runs terminal commands, creates branches and PRs, queries your context pack, and can escalate to a Worker run when the task outgrows a single session. Every chat session is recorded with a full transcript, tool call log, and timing data. ### Supported providers @@ -24,7 +24,7 @@ ADE detects which providers are available and lets you switch between them per-s Exploratory work, targeted edits, quick Q&A about your codebase, PR creation, test runs, and anything that resolves in a single focused session. - + Tasks spanning multiple files or agents, work requiring a formal audit trail, or anything you expect to run for more than 15–20 minutes. @@ -51,19 +51,19 @@ Chat in ADE is always scoped — it operates within a defined context boundary t - Project-wide context (all lanes, all branches, all pack snapshots) - Linear sync — it can read and write issues - - Delegation — it can spawn worker agents or new missions + - Delegation — it can spawn worker agents or new worker runs - Architecture decisions recorded in the project pack Use CTO chat for cross-cutting questions, planning, and delegation rather than hands-on coding. - - **Mission chat** is embedded inside a Mission's detail view. It is a direct channel to the mission's orchestrator while the mission is running. Use it to: + + **Worker run chat** is embedded inside a Worker run's detail view. It is a direct channel to the worker run's orchestrator while the worker run is running. Use it to: - Ask the orchestrator for a status summary - - Provide additional context mid-mission + - Provide additional context mid-worker run - Redirect a worker that has gone off course - Mission chat does not start new work — it communicates with the orchestrator of an already-running mission. + Worker run chat does not start new work — it communicates with the orchestrator of an already-running worker run. @@ -112,7 +112,7 @@ Chat in ADE is always scoped — it operates within a defined context boundary t Chat agents can leave **intentional evidence** of their work by calling the `ade proof` CLI — screenshots, short recordings, or ingested browser traces. There is no per-session policy chip anymore; the agent decides when a step deserves a proof, runs the CLI, and the captured artifact is linked to the chat session automatically. -Captured proofs appear in the session artifact panel and can be promoted to the lane's durable context, attached to a mission, or published to a PR or Linear issue. See [Proof Overview](/computer-use/overview) for the full workflow. +Captured proofs appear in the session artifact panel and can be promoted to the lane's durable context, attached to a worker run, or published to a PR or Linear issue. See [Proof Overview](/computer-use/overview) for the full workflow. --- @@ -141,7 +141,7 @@ You can **steer** an in-progress agent by sending a follow-up message before the ### Context integration -Chat sessions receive lane, session, artifact, mission, and explicit user context. For product or architecture knowledge, agents inspect the repository directly through ADE tools. +Chat sessions receive lane, session, artifact, worker run, and explicit user context. For product or architecture knowledge, agents inspect the repository directly through ADE tools. --- @@ -149,7 +149,7 @@ Chat sessions receive lane, session, artifact, mission, and explicit user contex - What the agent can do — file operations, git, PRs, missions, lane and action tools via the `ade` CLI, and more. + What the agent can do — file operations, git, PRs, worker runs, lane and action tools via the `ade` CLI, and more. Managing context windows, working with artifacts, chat history, and troubleshooting. diff --git a/computer-use/config.mdx b/computer-use/config.mdx index d25d3b60f..ff94fa6f4 100644 --- a/computer-use/config.mdx +++ b/computer-use/config.mdx @@ -29,7 +29,7 @@ By default, ADE retains all captured proofs until you explicitly dismiss them. T - For bulk cleanup, delete the row via the UI; the file is removed with it -Proofs linked to closed PRs and completed Linear issues remain on disk until explicitly cleaned up. This is deliberate — it keeps the audit trail intact long after a mission ends. +Proofs linked to closed PRs and completed Linear issues remain on disk until explicitly cleaned up. This is deliberate — it keeps the audit trail intact long after a worker run ends. --- @@ -57,7 +57,7 @@ Proofs linked to closed PRs and completed Linear issues remain on disk until exp - Check that the proof's workflow state is `published` (review states of `accepted` on their own do not publish). In the Mission Artifacts tab, open the proof, click **Publish**, and choose the PR as a target. + Check that the proof's workflow state is `published` (review states of `accepted` on their own do not publish). In the Worker run Artifacts tab, open the proof, click **Publish**, and choose the PR as a target. If the PR no longer exists or has been closed, the publish call returns an error in the CLI output. Re-target the proof to a different owner. diff --git a/computer-use/overview.mdx b/computer-use/overview.mdx index 4dfdd3a2b..f8c00d99c 100644 --- a/computer-use/overview.mdx +++ b/computer-use/overview.mdx @@ -1,12 +1,12 @@ --- title: "Proof Overview" -description: "Proof is ADE's intentional evidence system. Agents capture screenshots, recordings, traces, and logs via the `ade proof` CLI; ADE stores and links them to missions, chats, PRs, and Linear issues." +description: "Proof is ADE's intentional evidence system. Agents capture screenshots, recordings, traces, and logs via the `ade proof` CLI; ADE stores and links them to worker runs, chats, PRs, and Linear issues." icon: "camera" --- ## Overview -Proof is ADE's way of collecting **intentional evidence** during agent work. Workers and chat agents capture screenshots, short recordings, browser traces, verification snapshots, and console logs through the `ade proof` CLI. ADE stores the resulting artifacts, normalizes them, and links them to the mission, chat session, PR, or Linear issue that produced them. +Proof is ADE's way of collecting **intentional evidence** during agent work. Workers and chat agents capture screenshots, short recordings, browser traces, verification snapshots, and console logs through the `ade proof` CLI. ADE stores the resulting artifacts, normalizes them, and links them to the worker run, chat session, PR, or Linear issue that produced them. There is no separate computer-use broker, no backend negotiation, and no per-session policy chip. Agents simply call `ade proof` when they want to leave a trail. @@ -15,13 +15,13 @@ There is no separate computer-use broker, no backend negotiation, and no per-ses Proof is explicit. Agents decide when a piece of work deserves a screenshot or recording — there is no background recording of every action. - Every proof is stored with a canonical record: kind, owner, storage path, MIME type, and metadata. The same normalized schema powers mission artifact tabs, chat session history, and PR attachments. + Every proof is stored with a canonical record: kind, owner, storage path, MIME type, and metadata. The same normalized schema powers worker run artifact tabs, chat session history, and PR attachments. Agents use `ade proof capture`, `ade proof record`, `ade proof list`, and a few related commands. See the [Setup](/computer-use/setup) page for the full command list. - Proofs are linked to lanes, missions, chat sessions, PRs, and Linear issues. Review and publish them from the Mission Artifacts tab or the chat artifact panel. + Proofs are linked to lanes, worker runs, chat sessions, PRs, and Linear issues. Review and publish them from the Worker run Artifacts tab or the chat artifact panel. @@ -47,7 +47,7 @@ Screenshots and short recordings are the most common kinds, because they can be | Context | Where proofs appear | |---|---| -| Missions | The **Artifacts tab** on every mission detail view lists every proof captured during that mission | +| Workers | The **Artifacts tab** on every worker run detail view lists every proof captured during that worker run | | Agent Chat | The session artifact panel surfaces proofs captured inside a chat session | | Pull Requests | Proofs routed to a PR appear in the **Path to Merge** tab as inline attachments | | Linear Issues | Published proofs are posted as attachments on the linked Linear issue | diff --git a/computer-use/proofs.mdx b/computer-use/proofs.mdx index ef6d53666..6983651d0 100644 --- a/computer-use/proofs.mdx +++ b/computer-use/proofs.mdx @@ -20,15 +20,15 @@ Use screenshots for lightweight visual confirmation, recordings for flow review, ## Ownership Model -Every proof is a canonical record in ADE's SQLite database. Proofs can have multiple owners simultaneously — the same screenshot can be linked to a mission, a PR, and a Linear issue. +Every proof is a canonical record in ADE's SQLite database. Proofs can have multiple owners simultaneously — the same screenshot can be linked to a worker run, a PR, and a Linear issue. **Ownership types:** - **Lane** — the lane where the proof was captured -- **Mission** — the mission the agent was working on, if any +- **Worker run** — the worker run the agent was working on, if any - **Chat Session** — the chat session the agent was in, if any - **Pull Request** — if the proof is linked to an open PR - **Linear Issue** — if the proof is published to a Linear issue as an attachment -- **Orchestrator run / step / attempt** — finer-grained mission subdivisions +- **Orchestrator run / step / attempt** — finer-grained worker run subdivisions - **Automation run** — when the proof was captured inside an automation Agents attach owners when they call `ade proof`, and you can add or remove owners later from the proof's detail view. @@ -51,7 +51,7 @@ Each proof carries two orthogonal state fields: | State | Meaning | |---|---| | `evidence_only` | Captured but not yet promoted anywhere | -| `promoted` | Moved into a mission's artifacts pool | +| `promoted` | Moved into a worker run's artifacts pool | | `published` | Routed to an external owner (PR attachment, Linear attachment) | | `dismissed` | Removed from the active pool | @@ -59,13 +59,13 @@ A typical proof starts as `pending` / `evidence_only`, moves to `accepted` / `pr --- -## Reviewing Proofs in Missions +## Reviewing Proofs in Workers -Missions surface proofs in the **Artifacts tab** on the mission detail view. Each row shows the kind, title, source backend, owner, and a thumbnail. +Workers surface proofs in the **Artifacts tab** on the worker run detail view. Each row shows the kind, title, source backend, owner, and a thumbnail. - On a mission detail view, switch to the **Artifacts** tab. + On a worker run detail view, switch to the **Artifacts** tab. A side panel opens with the full media, the capture metadata, and any existing owners. @@ -74,7 +74,7 @@ Missions surface proofs in the **Artifacts tab** on the mission detail view. Eac Choose **Accept**, **Needs more**, or **Dismiss**. Your reviewer note is stored alongside the state. - Select one or more targets — the PR from this mission, a Linear issue, a chat session — and click **Publish**. ADE routes the proof to each target and updates the workflow state to `published`. + Select one or more targets — the PR from this worker run, a Linear issue, a chat session — and click **Publish**. ADE routes the proof to each target and updates the workflow state to `published`. @@ -85,7 +85,7 @@ Missions surface proofs in the **Artifacts tab** on the mission detail view. Eac When a chat agent captures a proof during a session, it appears in the session's artifact panel. From there you can: - Promote the proof to the lane's durable context -- Attach it to an existing mission's artifact pool +- Attach it to an existing worker run's artifact pool - Attach it to a PR or Linear issue - Dismiss it @@ -102,6 +102,6 @@ capture → pending / evidence_only → dismissed (excluded from the active pool) ``` -Every state transition is append-only: the original record is never overwritten. The History tab on each mission reflects every review and publish action, giving you a full audit trail. +Every state transition is append-only: the original record is never overwritten. The History tab on each worker run reflects every review and publish action, giving you a full audit trail. \ No newline at end of file diff --git a/computer-use/setup.mdx b/computer-use/setup.mdx index 79f60ede5..7edd46bfa 100644 --- a/computer-use/setup.mdx +++ b/computer-use/setup.mdx @@ -22,7 +22,7 @@ The first time `ade proof capture` runs, macOS asks for **Screen Recording** per ## The `ade proof` CLI -All proof flow happens through the `ade` CLI. Agents call these commands from terminal sessions inside a lane, and the results are stored in `.ade/artifacts/computer-use/` and linked to the lane, mission, chat session, PR, or Linear issue they originated from. +All proof flow happens through the `ade` CLI. Agents call these commands from terminal sessions inside a lane, and the results are stored in `.ade/artifacts/computer-use/` and linked to the lane, chat session, PR, or Linear issue they originated from. | Command | What it does | |---|---| @@ -32,7 +32,7 @@ All proof flow happens through the `ade` CLI. Agents call these commands from te | `ade proof record --seconds 20` | Capture a short screen recording | | `ade proof ingest --input-json '{...}'` | Ingest proofs produced by external tools (browser traces, verifications, console logs) | -`ade proof` accepts the same `--lane`, `--mission`, `--chat-session`, `--pr`, and `--linear-issue` owner flags as the rest of the ADE CLI, so agents can attach the captured proof to one or more owners in a single call. +`ade proof` accepts the same `--lane`, `--chat-session`, `--pr`, and `--linear-issue` owner flags as the rest of the ADE CLI, so agents can attach the captured proof to one or more owners in a single call. ### Typical Flow @@ -47,7 +47,7 @@ All proof flow happens through the `ade` CLI. Agents call these commands from te ADE stores the file under `.ade/artifacts/computer-use/` and records a `ComputerUseArtifact` row with kind, owner, and metadata. A link is created to the lane, and any additional owners passed via flags. - The proof shows up in the Mission Artifacts tab (if the agent was in a mission), the chat artifact panel (if in chat), or the PR attachments panel (if linked to a PR). + The proof shows up in the chat artifact panel, the PR attachments panel, or the Linear issue attachment list depending on its owners. @@ -63,7 +63,7 @@ ade proof ingest --input-json '{ { "kind": "console_logs", "title": "checkout console", "text": "..." } ], "owners": [ - { "kind": "mission", "id": "mission_abc" } + { "kind": "worker_task", "id": "worker_task_abc" } ] }' ``` @@ -83,4 +83,4 @@ ADE normalizes each entry into a proof record of the declared kind and links it Each artifact has a row in ADE's SQLite database with pointers to the file, the owners, the review state, and the workflow state. See [Configuration](/computer-use/config) for retention settings. - \ No newline at end of file + diff --git a/configuration/ai-providers.mdx b/configuration/ai-providers.mdx index acf8fb419..21c6b151f 100644 --- a/configuration/ai-providers.mdx +++ b/configuration/ai-providers.mdx @@ -178,7 +178,7 @@ Local models run entirely on your machine. No API key is needed. ADE communicate -Local models have significantly smaller context windows than cloud models (typically 4K–32K tokens versus 128K–200K). ADE's context compaction will trigger more frequently. For complex missions or large codebases, cloud models are strongly recommended. +Local models have significantly smaller context windows than cloud models (typically 4K–32K tokens versus 128K–200K). ADE's context compaction will trigger more frequently. For complex worker runs or large codebases, cloud models are strongly recommended. --- @@ -205,7 +205,7 @@ Local models have significantly smaller context windows than cloud models (typic ## Permission modes -Chat and Missions share the same permission picker. Each provider exposes a small set of presets that map directly to provider flags. +Chat and Workers share the same permission picker. Each provider exposes a small set of presets that map directly to provider flags. ### Claude @@ -227,7 +227,7 @@ Codex presets combine `--approval-policy` and `--sandbox`. | `full-auto` (Full access) | `never` | `danger-full-access` | You are running inside a stronger external sandbox. Skips every approval prompt. | | `config-toml` (Custom) | — | — | ADE passes no `--approval-policy` or `--sandbox`. Codex honors its own config (`~/.codex/config.toml`). | -There is no separate "Guarded edit" preset anymore. The previous Guarded edit preset is replaced by **Default permissions**, which is the same workspace-write sandbox but with the `on-request` approval policy rather than `on-failure`. The Codex sandbox in Missions is derived from the chosen mode — the separate sandbox dropdown has been removed from the Worker Permissions editor. +There is no separate "Guarded edit" preset anymore. The previous Guarded edit preset is replaced by **Default permissions**, which is the same workspace-write sandbox but with the `on-request` approval policy rather than `on-failure`. The Codex sandbox in Workers is derived from the chosen mode — the separate sandbox dropdown has been removed from the Worker Permissions editor. ### OpenCode @@ -244,18 +244,18 @@ ADE lets you assign different models to different agent roles. This is the recom ai: defaultModel: "claude-sonnet-4-6" # Fallback for all agents routingRules: - - role: "orchestrator" # Mission planning agent + - role: "orchestrator" # Worker run planning agent model: "claude-opus-4-7" - - role: "worker" # Mission execution agents + - role: "worker" # Worker run execution agents model: "claude-sonnet-4-6" - role: "automation" # Automation runs (cost-sensitive) model: "claude-haiku-4-5" - - role: "validator" # Mission validation agent + - role: "validator" # Worker run validation agent model: "claude-sonnet-4-6" ``` -For solo developers running many automations, routing automation runs to `claude-haiku-4-5` and reserving `claude-opus-4-7` for mission planning can reduce monthly spend by 60–80% with minimal quality impact on routine tasks. +For solo developers running many automations, routing automation runs to `claude-haiku-4-5` and reserving `claude-opus-4-7` for worker run planning can reduce monthly spend by 60–80% with minimal quality impact on routine tasks. --- @@ -268,7 +268,7 @@ Budget caps prevent runaway spend from long-running agents or automation loops. # .ade/ade.yaml ai: defaultBudgetPerSession: 1.00 # USD per chat session - defaultBudgetPerMission: 10.00 # USD per mission (all workers combined) + defaultBudgetPerWorkerRun: 10.00 # USD per delegated worker run defaultBudgetPerAutomation: 0.50 # USD per automation trigger monthlyBudgetCap: 100.00 # Hard monthly ceiling across all agents ``` @@ -277,8 +277,8 @@ ai: Applied to chat sessions in the Agent Chat pane. When the session's cumulative cost reaches this value, the agent receives a stop signal. You can raise the cap for the current session in the chat header without editing config. - - Applied to the entire mission run, summing across all workers. If the combined worker spend reaches the cap during execution, the orchestrator suspends remaining tasks and surfaces an intervention request asking whether to extend the budget or abandon the mission. + + Applied to the entire delegated worker run, summing across all workers. If the combined worker spend reaches the cap during execution, the orchestrator suspends remaining tasks and surfaces an intervention request asking whether to extend the budget or abandon the worker run. Applied per automation trigger event. Each time an automation fires, it gets a fresh budget for that run. Useful for keeping PR review automations economical. @@ -360,8 +360,8 @@ API keys are handled according to ADE's trust boundary model: Review the trust boundary model and configure per-agent access rules. - - Learn how model routing rules apply during mission planning and execution phases. + + Learn how model routing rules apply during worker run planning and execution phases. Understand how API keys are protected by ADE's trust boundary architecture. diff --git a/configuration/overview.mdx b/configuration/overview.mdx index 34e7242b2..83369088d 100644 --- a/configuration/overview.mdx +++ b/configuration/overview.mdx @@ -88,7 +88,7 @@ description: "Optional project description shown in ADE's title bar" ai: defaultModel: "claude-opus-4-7" # or "claude-sonnet-4-6", "gpt-4o", etc. defaultBudgetPerSession: 1.00 # USD cap per chat session - defaultBudgetPerMission: 5.00 # USD cap per mission run + defaultBudgetPerWorkerRun: 5.00 # USD cap per delegated worker run defaultBudgetPerAutomation: 0.50 # USD cap per automation execution monthlyBudgetCap: 100.00 # Hard monthly cap across all agents @@ -145,13 +145,13 @@ trust: - All `ai.*` values are defaults. They can be overridden per-mission, per-worker, or per-automation at creation time. The `monthlyBudgetCap` is a hard ceiling — once reached, all agent activity pauses until the next billing period or you manually raise the cap in Settings → Budget. + All `ai.*` values are defaults. They can be overridden per-worker run, per-worker, or per-automation at creation time. The `monthlyBudgetCap` is a hard ceiling — once reached, all agent activity pauses until the next billing period or you manually raise the cap in Settings → Budget. Process definitions describe how to start development servers. ADE uses these to populate the lane startup commands and to generate the process panel in the Lanes view. The `group` field organizes processes visually in the UI. - ADE uses the `run` command in agent contexts when a worker needs to execute tests. The `watch` command is used in lane terminal sessions for continuous testing. The `ci` command is used during Mission validation phases and automation triggers. + ADE uses the `run` command in agent contexts when a worker needs to execute tests. The `watch` command is used in lane terminal sessions for continuous testing. The `ci` command is used during Worker run validation phases and automation triggers. Lane profiles are templates applied when creating new lanes. A profile sets the base port offset, environment variables, and startup commands. When you create a lane and select a profile, all profile values are applied as the lane's initial configuration. @@ -187,7 +187,7 @@ terminal: fontSize: 13 notifications: - missionComplete: true + workerRunComplete: true conflictDetected: true budgetWarning: true prStatusChange: false diff --git a/configuration/permissions.mdx b/configuration/permissions.mdx index cc0531eb9..a310cfc2a 100644 --- a/configuration/permissions.mdx +++ b/configuration/permissions.mdx @@ -67,7 +67,7 @@ ADE is built on a **local-first, trust boundary model**. Your source code, API k ADE monitors `ade.yaml` using filesystem events. Any change — from a `git pull`, a direct edit, or an automated tool — triggers the approval workflow immediately. - All command execution sourced from `ade.yaml` pauses. This includes: automation triggers, process startup commands, test suite runs, and mission executors defined in the config. Agents that are mid-session continue their current operation but cannot launch new commands from the updated config. + All command execution sourced from `ade.yaml` pauses. This includes: automation triggers, process startup commands, test suite runs, and worker run executors defined in the config. Agents that are mid-session continue their current operation but cannot launch new commands from the updated config. ADE shows a structured diff of every changed field in `ade.yaml`. Fields that define commands (`processes[*].command`, `automations[*].executor`, `testSuites[*].run`, `testSuites[*].ci`) are highlighted with a distinct visual treatment — these are the fields that matter most for security review. @@ -105,8 +105,8 @@ Every agent type in ADE has a default permission level. You can override these a | Agent Type | Default Level | Override Location | |------------|--------------|------------------| | CTO agent | `write-external` | CTO Settings | -| Mission orchestrator | `write-git` | Mission creation dialog | -| Mission worker | `write-git` | Mission creation dialog → Per-worker | +| Worker run orchestrator | `write-git` | Worker run creation dialog | +| Worker run worker | `write-git` | Worker run creation dialog → Per-worker | | Automation bot | `read-only` | Automation config → Tool Palette | | Chat agent | `write-local` | Chat header → Session Permissions | @@ -244,7 +244,7 @@ permissions: ``` -`allowForcePush: false` is the default and should almost never be changed. Force pushing to shared branches can irrecoverably destroy commit history. If a mission or automation attempts a force push, ADE blocks it and logs the attempt — it is not silently ignored. +`allowForcePush: false` is the default and should almost never be changed. Force pushing to shared branches can irrecoverably destroy commit history. If a worker run or automation attempts a force push, ADE blocks it and logs the attempt — it is not silently ignored. ### Agent-Specific Git Rules @@ -343,14 +343,14 @@ Every agent action that touches sensitive resources is logged to `.ade/artifacts | ADE action call | Agent ID, domain, action name, args (secrets redacted), response summary, timestamp | | Permission denied | Agent ID, tool attempted, reason, rule matched, timestamp | | Config approval | Approving user, old SHA, new SHA, changed fields, timestamp | -| Budget cap reached | Agent ID, session/mission, cap type, amount, timestamp | +| Budget cap reached | Agent ID, session/worker run, cap type, amount, timestamp | ### Audit Log Format ``` 2026-03-12T14:23:01.042Z [audit] tool_call agent=worker-3 session=s_abc123 tool=ade_write_file path=src/components/Button.tsx bytes=2048 - mission=m_xyz789 lane=lane/feature-auth result=success + worker run=m_xyz789 lane=lane/feature-auth result=success 2026-03-12T14:23:05.881Z [audit] permission_denied agent=automation-bot tool=ade_run_command command="rm -rf node_modules" @@ -385,8 +385,8 @@ Every agent action that touches sensitive resources is logged to `.ade/artifacts Review `.ade/artifacts/audit.log` periodically — especially the `permission_denied` entries. Frequent denials from a specific agent may indicate a misconfigured automation or an agent that is not working within its expected scope. They may also reveal a mistake in your allow/block rules that is blocking legitimate work. - - For missions that touch critical infrastructure, production configuration, or security-sensitive files, set a restrictive permission profile at mission creation: limit to specific paths, require Ask-First for all terminal commands, and lock the tool palette down to the minimum action set the mission needs. The mission-level override applies to all workers spawned by that mission, regardless of their default permission level. + + For worker runs that touch critical infrastructure, production configuration, or security-sensitive files, set a restrictive permission profile at worker run creation: limit to specific paths, require Ask-First for all terminal commands, and lock the tool palette down to the minimum action set the worker run needs. The worker run-level override applies to all workers spawned by that worker run, regardless of their default permission level. diff --git a/configuration/settings.mdx b/configuration/settings.mdx index 15a644411..7e9e123c2 100644 --- a/configuration/settings.mdx +++ b/configuration/settings.mdx @@ -128,15 +128,15 @@ ADE's default model applies to all agents unless you set role-specific overrides | Role | Description | Recommended Model | |------|-------------|-----------------| -| Orchestrator | Mission planning and worker coordination | `claude-opus-4-7` | -| Worker | Mission execution tasks | `claude-sonnet-4-6` | -| Validator | Mission validation and review | `claude-sonnet-4-6` | +| Orchestrator | Worker run planning and worker coordination | `claude-opus-4-7` | +| Worker | Worker run execution tasks | `claude-sonnet-4-6` | +| Validator | Worker run validation and review | `claude-sonnet-4-6` | | Automation | Event-driven automation runs | `claude-haiku-4-5` | | Chat | Interactive chat sessions | `claude-sonnet-4-6` | | CTO | Project-level leadership and delegation | `claude-opus-4-7` | -Routing automation runs to `claude-haiku-4-5` while keeping `claude-opus-4-7` for mission planning is the single most impactful cost optimization for users running frequent automations. Most PR-review and routine triage tasks do not require Opus-level reasoning. +Routing automation runs to `claude-haiku-4-5` while keeping `claude-opus-4-7` for worker run planning is the single most impactful cost optimization for users running frequent automations. Most PR-review and routine triage tasks do not require Opus-level reasoning. ### Context Management @@ -175,7 +175,7 @@ Mobile Push is local to the desktop install (it manages device registration on t Open the ADE iOS app and scan the registration QR code. The device is registered to your ADE install within a few seconds. - Toggle individual event types. The most useful defaults: mission complete, mission failed, and intervention required. + Toggle individual event types. The most useful defaults: worker run complete, worker run failed, and intervention required. @@ -204,7 +204,7 @@ The pairing panel also surfaces everything the old Sync section managed: | Sync history | Per-domain last-synced timestamps | — | -Sync uses cr-sqlite for CRDT replication. Missions, lanes, and session history all replicate. `.ade/artifacts/` blobs are fetched on demand by the requesting device. +Sync uses cr-sqlite for CRDT replication. Workers, lanes, and session history all replicate. `.ade/artifacts/` blobs are fetched on demand by the requesting device. --- @@ -222,7 +222,7 @@ ADE uses GitHub OAuth for PR management. A GitHub token can also be supplied in | OAuth connection | Connect or disconnect your GitHub account | | Organization access | Manage which GitHub orgs ADE can access | | Default branch | Branch ADE opens PRs against (default: `main`) | -| PR auto-link | Automatically link PRs to ADE missions and lanes when the PR branch matches a lane | +| PR auto-link | Automatically link PRs to ADE worker runs and lanes when the PR branch matches a lane | | Draft PRs by default | Create PRs as drafts until explicitly marked ready | ### Linear @@ -234,7 +234,7 @@ ADE connects to Linear for bidirectional issue sync. | API key | Linear API key (stored in `local.secret.yaml`) | | Sync direction | `bidirectional`, `read-only` (ADE reads Linear), `disabled` | | Default team | Linear team for issue creation | -| Label mappings | Map ADE mission states to Linear issue labels | +| Label mappings | Map ADE worker run states to Linear issue labels | | Issue → Lane auto-create | Automatically create a lane when a Linear issue is assigned to you | ### Proof Backends @@ -267,7 +267,7 @@ The usage panel shows three graph views, each switchable between bar and line ch - **Weekly**: Per-week totals for the last 12 weeks, broken down by agent role - **Monthly**: Per-month totals for the last 12 months, broken down by lane -Click any bar segment to drill into the individual sessions or missions that contributed to that spend. +Click any bar segment to drill into the individual sessions or worker runs that contributed to that spend. ### Monthly Cap @@ -278,7 +278,7 @@ Click any bar segment to drill into the individual sessions or missions that con | Hard stop | Pause all agent activity when the cap is reached | On | -The monthly cap is a hard stop. Once reached, all agent activity (chat, missions, automations, CTO heartbeats) pauses until either the cap is raised or the calendar month rolls over. ADE sends a notification when you reach 80% and again at 100%. +The monthly cap is a hard stop. Once reached, all agent activity (chat, worker runs, automations, CTO heartbeats) pauses until either the cap is raised or the calendar month rolls over. ADE sends a notification when you reach 80% and again at 100%. --- @@ -293,7 +293,7 @@ The monthly cap is a hard stop. Once reached, all agent activity (chat, missions - **Orchestrator model**: `claude-opus-4-7` - **Automation model**: `claude-haiku-4-5` - **Monthly cap**: $50.00 - - **Auto-refresh events**: On PR create + On mission start + - **Auto-refresh events**: On PR create + On worker run start Emphasize observability and shared standards: @@ -310,7 +310,7 @@ The monthly cap is a hard stop. Once reached, all agent activity (chat, missions - Configure per-role model routing (Opus for planning, Sonnet for execution, Haiku for automation) - Set a high monthly cap and lean on the Usage panel to spot runaway spend - Set compaction threshold to 60% for earlier and more frequent compaction - - Pair an iPhone from **Mobile Push** so pages land on your phone when a mission needs intervention + - Pair an iPhone from **Mobile Push** so pages land on your phone when a worker run needs intervention diff --git a/cto/linear.mdx b/cto/linear.mdx index a3b687e63..7bdffa8fe 100644 --- a/cto/linear.mdx +++ b/cto/linear.mdx @@ -6,7 +6,7 @@ icon: "arrow-right-arrow-left" ## How the CTO Uses Linear -The CTO owns ADE's bidirectional Linear integration. It monitors your workspace for issue changes, routes them to the right execution target (worker, mission, or PR convergence), and pushes updates back when work completes — tracking the full lifecycle from intake to closeout based on configurable workflow definitions. +The CTO owns ADE's bidirectional Linear integration. It monitors your workspace for issue changes, routes them to the right execution target (worker, worker run, or PR convergence), and pushes updates back when work completes — tracking the full lifecycle from intake to closeout based on configurable workflow definitions. --- @@ -27,7 +27,7 @@ The CTO owns ADE's bidirectional Linear integration. It monitors your workspace Set up workflow rules that map Linear events to ADE actions. Each workflow definition specifies: - **Trigger conditions** — which issues match (assignee, labels, priority, team, state transitions) - - **Execution target** — what ADE does (launch a mission, start a worker session, open a PR convergence run) + - **Execution target** — what ADE does (launch a worker run, start a worker session, open a PR convergence run) - **Lane selection** — where the work happens (primary lane, fresh issue-specific lane, or operator-prompted) - **Closeout policy** — what happens when the work completes (update issue state, add comments, apply labels) @@ -59,7 +59,7 @@ When a workflow triggers, the CTO dispatches work to one of these targets: | Target | When to use | |--------|-------------| -| **Mission** | Complex issues that need a structured plan, multiple steps, and validation | +| **Worker run** | Complex issues that need a structured plan, multiple steps, and validation | | **Employee session** | Issues where a single agent chat session can handle the work | | **Worker run** | Background worker execution managed by the CTO | | **PR resolution** | Issues that map directly to an existing PR that needs convergence work | @@ -111,7 +111,7 @@ When a Linear issue changes and matches a workflow trigger, the CTO processes it The CTO's routing service evaluates which workflow to apply, resolves the execution target and lane selection, and prepares the dispatch context. - The dispatcher creates the execution target — starts a mission, opens a chat session, or launches a worker run. The issue's description, comments, and attachments are injected as context. + The dispatcher creates the execution target — starts a worker run, opens a chat session, or launches a worker run. The issue's description, comments, and attachments are injected as context. A workflow run is created with per-step status tracking. The CTO monitors the run and updates Linear as milestones are reached. @@ -132,7 +132,7 @@ The CTO pushes updates back to Linear through the outbound service: - **Workpad comments** — maintain a persistent "workpad" comment on each issue that the CTO updates with progress notes, PR links, and analysis results. The workpad uses content hashing to avoid duplicate updates. - **New sub-issues** — when the CTO discovers additional scope during implementation, it can create child issues in Linear - **Label management** — apply or remove labels based on workflow outcomes -- **Proof artifacts** — attach mission artifacts, test reports, or screenshots as Linear attachments or comment links +- **Proof artifacts** — attach worker run artifacts, test reports, or screenshots as Linear attachments or comment links --- diff --git a/cto/overview.mdx b/cto/overview.mdx index c1ca7feef..724f81f17 100644 --- a/cto/overview.mdx +++ b/cto/overview.mdx @@ -22,7 +22,7 @@ The **CTO** (Chief Technical Officer) is ADE's persistent, project-scoped AI age Drives PRs to merge readiness autonomously — starts convergence rounds, monitors CI and review comments, and manages the full resolution loop via five dedicated operator tools. - Uses current lane, PR, Linear, mission, and repository context to make delegation decisions. + Uses current lane, PR, Linear, worker run, and repository context to make delegation decisions. @@ -30,17 +30,17 @@ The **CTO** (Chief Technical Officer) is ADE's persistent, project-scoped AI age ## CTO vs. Regular Agents -Understanding how the CTO differs from the agents you spawn manually (in chat sessions or missions) helps you know when to use each. +Understanding how the CTO differs from the agents you spawn manually (in chat sessions or worker runs) helps you know when to use each. | | **CTO Agent** | **Regular / Worker Agent** | |--|---------------|---------------------------| | **Lifecycle** | Always on; persistent across sessions | Ephemeral; lives for one task or session | -| **Scope** | Entire project | Specific lane, task, or mission | -| **Context** | Project, lane, PR, Linear, and mission state | Current session or assigned lane | +| **Scope** | Entire project | Specific lane, task, or worker run | +| **Context** | Project, lane, PR, Linear, and worker run state | Current session or assigned lane | | **Triggers** | Heartbeat schedule + events + manual chat | Explicit user action or automation trigger | -| **Authority** | Can spawn lanes, create missions, delegate to workers, drive PR convergence | Operates within its assigned lane/context | +| **Authority** | Can spawn lanes, create worker runs, delegate to workers, drive PR convergence | Operates within its assigned lane/context | | **Linear access** | Full bidirectional sync (owner) | Read-only via ADE CLI tools, if granted | -| **Primary UI** | CTO tab | Lane terminal, Chat pane, Missions tab | +| **Primary UI** | CTO tab | Lane terminal, Chat pane, Workers tab | --- diff --git a/cto/workers.mdx b/cto/workers.mdx index 60a615c47..e56dd09bb 100644 --- a/cto/workers.mdx +++ b/cto/workers.mdx @@ -131,7 +131,7 @@ trigger: project: "Backend API" executor: mode: cto-route - message: "A new Linear issue has been created. Triage it and decide whether to create a mission, assign to a worker, or flag for human review." + message: "A new Linear issue has been created. Triage it and decide whether to create a worker run, assign to a worker, or flag for human review." ``` --- diff --git a/docs.json b/docs.json index 0b432a3f9..efb70050d 100644 --- a/docs.json +++ b/docs.json @@ -134,17 +134,6 @@ "guides/automation-rules" ] }, - { - "group": "Missions", - "icon": "bullseye", - "pages": [ - "missions/overview", - "missions/creating", - "missions/workers", - "missions/budget", - "guides/first-mission" - ] - }, { "group": "CTO Agent", "icon": "crown", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index afe33afa0..afe6cf665 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ Consolidated technical reference for the ADE (Agentic Development Environment) s ADE is a local-first development control plane that orchestrates AI-assisted software engineering across parallel worktrees. The center of the system is a **per-machine ADE runtime daemon** (`apps/ade-cli/`, started with `ade serve`). The daemon hosts every project on that machine through a project registry and exposes a multi-project JSON-RPC surface on a Unix socket / Windows named pipe at `~/.ade/sock/ade.sock`. Desktop, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all peer **clients** that bind to a runtime — local or remote — and invoke runtime-owned actions through that one surface. -The runtime owns everything that needs to survive a client closing: worktree-per-lane git isolation, a multi-provider AI runtime, a deterministic orchestrator for multi-step missions, a Linear-integrated CTO agent acting as a team lead, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, the sync host that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). +The runtime owns everything that needs to survive a client closing: worktree-per-lane git isolation, a multi-provider AI runtime, a Linear-integrated CTO agent acting as a team lead, worker delegation, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, the sync host that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). ADE ships as four runtime/client packages plus the marketing site: @@ -94,7 +94,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This **Session identity.** The runtime resolves caller role from ADE context env vars and command flags. Role vocabulary: `cto`, `orchestrator`, `agent`, `external`, `evaluator`. -**Action surface.** First-class command families cover lanes, git, diffs, files, PRs, path-to-merge, runs, shells, chats, agents, CTO, Linear, tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE socket), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), and a generic `ade actions run ` escape hatch for every registered ADE service action. The action allow-list adds two domains for these surfaces: `app_control` (every public method on `AppControlService`) and `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`). +**Action surface.** First-class command families cover lanes, git, diffs, files, PRs, path-to-merge, shells, chats, agents, CTO, Linear, tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE socket), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), and a generic `ade actions run ` escape hatch for every registered ADE service action. The action allow-list adds two domains for these surfaces: `app_control` (every public method on `AppControlService`) and `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`). **Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`. `attach` infers the artifact kind from the file extension and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. All proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. @@ -165,7 +165,7 @@ Native SwiftUI app acting as a controller. It pairs with a runtime daemon over W - Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, `LiveActivityCoordinator.swift`. - Shipped tabs: Lanes, Files, Work, PRs, CTO, Settings. - Shipped: APNs push pipeline (runtime-side `apnsService` + `notificationEventBus` → iOS `AppDelegate` + `NotificationCategories` + Notification Service Extension), workspace Live Activity (Lock Screen + Dynamic Island), Home Screen / Lock Screen / Control Center widgets. -- Planned: Missions, Automations, Graph, History tabs; iPad layout; Spotlight. +- Planned: Automations, Graph, History tabs; iPad layout; Spotlight. - Target: iOS 26+, iPhone + iPad. ### 2.5 Web app (`apps/web/`) @@ -197,21 +197,20 @@ Schema bootstrap in `kvDb.ts` creates ~103 tables. Anchor tables for agents read | Table | Purpose | |-------|---------| | `projects` | One row per opened repo. Keyed by `root_path`. | -| `lanes` | Worktree-backed units of work. Types: `primary`, `worktree`, `attached`. Supports parent/child stacks, mission binding, color/icon/tags. | +| `lanes` | Worktree-backed units of work. Types: `primary`, `worktree`, `attached`. Supports parent/child stacks, run binding, color/icon/tags. | | `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. | | `runtime_processes` | Process-liveness registry. Every ADE process (desktop main, TUI runtime, `ade serve` daemon) inserts a row on boot keyed by `pid` and refreshes `last_seen` on a 5 s heartbeat. Reconcile / dispose paths cross-reference `terminal_sessions.owner_pid` against the live rows in this table to tell "row whose owner crashed" from "row a sibling process is actively managing." See §3.5. | | `session_deltas` | Post-session diff stats + touched files + failure lines. Input to pack generation. | | `operations` | Audit log of every significant mutation (git, pack updates). Pre/post HEAD SHAs enable undo. | | `process_definitions` / `process_runtime` / `process_runs` | Managed-process lifecycle (derived from `ade.yaml`). | | `test_suites` / `test_runs` | Declared test suites and their execution history. | -| `missions` / `mission_runs` / `mission_steps` / `mission_step_attempts` | Mission lifecycle with runs, steps, attempts. | | `pull_requests` / `pr_review_threads` / `pr_checks` | GitHub PR projections with queue and stack metadata. | | `integration_proposals` | PR merge-plan simulations. Stores source lanes, pairwise results, sequential resolution state, optional adopted merge target (`preferred_integration_lane_id`), and merge-target drift snapshot (`merge_into_head_sha`). | | `computer_use_artifacts` + `computer_use_artifact_links` | Canonical proof-artifact records and cross-domain ownership. | | `devices` + `sync_cluster_state` | Device registry and singleton host-authority row (host is `brain_device_id` internally; legacy naming). | | `kv` | Generic key-value store for UI layout, config trust hashes, misc settings, and short-lived recovery records such as `agent-chat-parallel-launch::`. | -Types for these tables are split into domain modules under `apps/desktop/src/shared/types/`. The barrel `index.ts` re-exports `core`, `models`, `git`, `lanes`, `conflicts`, `prs`, `files`, `sessions`, `chat`, `missions`, `orchestrator`, `config`, `automations`, `packs`, `budget`, `usage`, and more. Feature docs under `docs/features/` call out the table subsets that are load-bearing for each surface. +Types for these tables are split into domain modules under `apps/desktop/src/shared/types/`. The barrel `index.ts` re-exports `core`, `models`, `git`, `lanes`, `conflicts`, `prs`, `files`, `sessions`, `chat`, `config`, `automations`, `packs`, `budget`, `usage`, and more. Feature docs under `docs/features/` call out the table subsets that are load-bearing for each surface. ### 3.3 Filesystem state @@ -232,7 +231,7 @@ Types for these tables are split into domain modules under `apps/desktop/src/sha │ │ ├── CURRENT.md # Running status markdown (ignored) │ │ └── daily/.md │ ├── agents// # Per-worker identity and daily logs (runtime, ignored) -│ ├── templates/ # Lane/mission templates (tracked when human-authored) +│ ├── templates/ # Lane and automation templates (tracked when human-authored) │ ├── skills/ # Exported skill markdown (tracked when human-authored) │ ├── workflows/linear/ # Linear workflow config (tracked when present) │ ├── project-icons/ # Imported project icon overrides (tracked when ade.yaml.iconPath points at one) @@ -279,14 +278,14 @@ Roles are open-ended strings; today's vocabulary is `desktop-main`, `ade-serve-d - Schema is defined idempotently — `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`. - One-time schema-compat migration at startup: retrofits `NOT NULL` on PKs and strips UNIQUE/FK constraints incompatible with cr-sqlite CRRs. A pre-cr-sqlite backup (`.pre-crsqlite-w1.bak`) is written on first CRR enablement. - Feature migrations add columns via `ALTER TABLE ADD COLUMN`, wrapped by `crsql_begin_alter`/`crsql_commit_alter` to stay CRR-safe. -- Targeted per-domain migrations live alongside their domain tests: `kvDb.missionsMigration.test.ts`, `kvDb.orchestratorMigration.test.ts`, `kvDb.workerAgentsMigration.test.ts`. +- Targeted per-domain migrations live alongside their domain tests, for example `kvDb.workerAgentsMigration.test.ts`. - The canonical iOS bootstrap schema is exported from desktop `kvDb.ts` to `apps/ios/ADE/Resources/DatabaseBootstrap.sql` so iOS stays schema-compatible. --- ## 4. AI Integration Layer -Service entry points live under `apps/desktop/src/main/services/ai/`. The subsystem has four parts: provider-routed execution, permission profiles, ADE CLI-backed tool surfaces, and a deterministic orchestrator on top of those. +Service entry points live under `apps/desktop/src/main/services/ai/`. The subsystem has three parts: provider-routed execution, permission profiles, and ADE CLI-backed tool surfaces. ### 4.1 Provider routing @@ -315,8 +314,8 @@ Permission configuration is class-based, not provider-bucketed: - `permissionConfig.cli` — for CLI-wrapped models. Claude uses `claudePermissionMode` (`default`, `auto`, `acceptEdits`, `bypassPermissions`, `plan`); Codex uses `approvalMode` (`untrusted`, `on-request`, `on-failure`, `never`) + `sandboxPermissions` (`read-only`, `workspace-write`, `danger-full-access`). - `permissionConfig.inProcess` — for API/local models. ADE-defined planning/coding tool profiles constitute the full tool surface. -- **ADE-owned tools** (repo mutation, mission control, context export) always enforce ADE's own permission and policy layers regardless of provider mode — preserving the audit boundary. -- **Sandbox budgets**: `maxBudgetUsd` per-session cap for Claude; per-task daily budgets for narratives/PR descriptions/terminal summaries/mission planning/orchestrator. +- **ADE-owned tools** (repo mutation, context export, proof registration) always enforce ADE's own permission and policy layers regardless of provider mode — preserving the audit boundary. +- **Sandbox budgets**: `maxBudgetUsd` per-session cap for Claude; per-task daily budgets for narratives, PR descriptions, and terminal summaries. ### 4.3 Tool system @@ -325,7 +324,7 @@ Agent tools are split by domain: | File | Domain | |------|--------| | `ai/tools/universalTools.ts` | Mutating tools (`bash`, `writeFile`, `editFile`), read/search tools, web tools, todos, and ask-user. | -| `ai/tools/workflowTools.ts` | Mission / workflow interaction tools. | +| `ai/tools/workflowTools.ts` | Workflow interaction tools. | | `ai/tools/ctoOperatorTools.ts` | CTO-only operator tools. | | `ai/tools/linearTools.ts` | Linear integration tool surface. | | `ai/tools/webFetch.ts` / `webSearch.ts` | Outbound web access. | @@ -343,23 +342,9 @@ Agent tools are split by domain: - `ModelProviderGroup` = `"claude" | "codex" | "opencode" | "cursor" | "droid"`. Cursor and Droid each have their own top-level provider group used by the model picker, identity routing, and tracked CLI provider catalog. - Helpers: `getModelById`, `getModelPricing`, `updateModelPricingInRegistry`, `replaceDynamicOpenCodeModelDescriptors`, `resolveProviderGroupForModel`, `resolveModelDescriptorForProvider`, `getRuntimeModelRefForDescriptor`, `modelSupportsServiceTier(descriptor, tier)` / `modelSupportsFastMode(descriptor)`. - Reasoning tier passthrough (`providerOptions.ts`) maps tier strings directly to each provider's native config (`thinking.type`, `reasoningEffort`, `thinkingConfig.thinkingLevel`, etc.) — no arbitrary token budgets. The Claude vocabulary is `low | medium | high | max`. -- Model profiles (`modelProfiles.ts`) derive the Missions UI model catalog and per-call-type intelligence defaults from `MODEL_REGISTRY` rather than maintaining parallel lists. +Interactive chat (Terminals, Work), CTO delegation, and automation-launched agent sessions flow through the unified executor with the same permission plumbing. -### 4.5 AI Orchestrator (deterministic runtime) - -`apps/desktop/src/main/services/orchestrator/` contains the deterministic state machine that tracks mission runs, steps, attempts, and claims: - -- `aiOrchestratorService.ts` — orchestrator API. -- `coordinatorAgent.ts` + `coordinatorTools.ts` + `coordinatorSession.ts` — the intelligent coordinator that makes strategic decisions (spawn, replan, validation routing, lane transfer, escalation). -- `orchestrationRuntime.ts`, `missionLifecycle.ts`, `missionStateDoc.ts`, `executionPolicy.ts`, `phaseEngine.ts` — runtime invariants and state. -- `adaptiveRuntime.ts`, `metaReasoner.ts` — smart fan-out and adaptive behavior. -- `modelConfigResolver.ts` — strict phase-authoritative model resolution (explicit override → current phase model; no role-level fallback). -- `delegationContracts.ts`, `missionBudgetService.ts` — contract + budget enforcement. -- Validation baseline: required validation contracts are runtime-enforced. Auto-spawned validator steps per target step. Missing validation blocks phase transitions with signals `validation_contract_unfulfilled`, `validation_self_check_reminder`, `validation_auto_spawned`, `validation_gate_blocked`. - -Interactive chat (Terminals, Work) bypasses mission runtime semantics but still flows through the unified executor with the same permission plumbing. - -Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/agents/README.md), and [Missions orchestration](./features/missions/orchestration.md). +Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/agents/README.md), [CTO](./features/cto/README.md), and [Automations](./features/automations/README.md). --- @@ -401,7 +386,6 @@ ade.prs.* # stacked PR queue, integration, issue inventory, # ade.prs.pathToMerge.stop) and ade.prs.retargetBase used # by the queue Automate Merging modal ade.conflicts.* # risk matrix, simulation, proposals -ade.missions.* / ade.orchestrator.* ade.cto.* # identity, agent roster, Linear ade.sessions.* # terminal session CRUD ade.agentChat.* # agent chat sessions, model inventory, parallel launch state. @@ -438,7 +422,7 @@ ade.updates.* - Every handler emits structured tracing: `ipc.invoke.begin`, `ipc.invoke.done`, `ipc.invoke.failed` with call ID, channel, window ID, duration, and summarized args/results. - `AppContext` indirection: handlers close over a context pointer that swaps atomically on project switch, so IPC channels remain registered across project transitions. - **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its **set** of open project roots before routing into services. Two maps in `main.ts` drive this: `windowProjectRoots` tracks the active foreground project per window, and `windowProjectTabRoots` tracks every project root that window currently has open as a tab. Project-scoped event broadcasts (`emitToProjectWindows`) deliver to any window whose active **or** open-tab set contains the project, so background tabs keep receiving live updates. `ade.app.getWindowSession` returns `{ project, binding, openProjectTabs }` for the requesting window; the renderer mirrors its open-tab list back to main with `ade.app.setWindowProjectTabs({ rootPaths })` so the main process can keep those project contexts warm and clean up on window close. Renderer tab switches use cached project/lane snapshots for warm activation, retain caches for every open tab root even if a project is absent from recents, keep Work and Lanes mounted after first visit, and cover cold switches with a project-transition veil. -- **Project context retention.** `MAX_WARM_IDLE_PROJECT_CONTEXTS = 100` is a soft cap for project contexts with no user work. `hasActiveProjectWorkloads(ctx)` protects any context that has live chat sessions (via `agentChatService.hasRetainableSessions()` — any session the user hasn't explicitly closed or deleted, not just mid-turn ones), live PTYs (`ptyService.hasLiveSessions()`), active missions, or queued tests. Eviction is best-effort and never tears down a context with work; the cap exists only as a safety valve against opening hundreds of empty projects in a long session. +- **Project context retention.** `MAX_WARM_IDLE_PROJECT_CONTEXTS = 100` is a soft cap for project contexts with no user work. `hasActiveProjectWorkloads(ctx)` protects any context that has live chat sessions (via `agentChatService.hasRetainableSessions()` — any session the user hasn't explicitly closed or deleted, not just mid-turn ones), live PTYs (`ptyService.hasLiveSessions()`), active managed processes, or queued tests. Eviction is best-effort and never tears down a context with work; the cap exists only as a safety valve against opening hundreds of empty projects in a long session. ### 5.4 Event subscriptions (push, not poll) @@ -452,15 +436,12 @@ High-frequency events flow from main → renderer via `webContents.send(channel, | `ade.tests.event` | testService | Test panel | | `ade.conflicts.event` | conflictService | Conflicts page, Graph overlay | | `ade.prs.event` | prPollingService | PRs page, stacked queue | -| `ade.missions.event` / `ade.orchestrator.event` | missionService / orchestrator | useMissionsStore (debounced) | | `ade.agents.event` | CTO/worker services | CTO tab feed | | `ade.lanes.rebaseSuggestions.event` / `ade.lanes.autoRebase.event` / `ade.lanes.rebase.event` | rebase services | Lanes + Graph | | `ade.project.missing` | projectService | Shell banner | | `ade.project.state.event` | projectState | Startup flow | | `ade.sync.*` events | syncService | Settings → Sync | -Consolidated reads: `getFullMissionView` returns metadata, run, steps, chat threads, interventions, artifacts, and usage in one IPC — replacing 5+ per-mission-selection calls. - Renderer telemetry events flow back to main: `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. --- @@ -476,7 +457,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `appControl/` | `appControlService.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts` | In-app web browser owned by the main process. Allocates `WebContentsView` tabs against the shared `persist:ade-browser` partition (cap 10), positions them over a renderer-supplied bounds rect, drives navigation / tabs / reload / back / forward, attaches the Chrome DevTools Protocol debugger for inspect-mode hit tests, captures screenshots, and emits `BuiltInBrowserEventPayload`s to subscribers. Consumed by `ChatBuiltInBrowserPanel` (Work sidebar Browser tab) and by `openUrlInAdeBrowser()` in the renderer so renderer-side link clicks open inside ADE rather than the system browser. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | -| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + mission worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool, formalizes the cross-runtime event vocabulary, recovers sessions on restart, and derives prompt-based lane names for parallel model launches. | +| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions. Builds Claude messages, hosts the Cursor SDK in a Node worker pool, formalizes the cross-runtime event vocabulary, recovers sessions on restart, and derives prompt-based lane names for parallel model launches. | | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | @@ -497,10 +478,8 @@ Most services described here live under `apps/desktop/src/main/services/ | `logging/` | `logger.ts` | File-backed structured logger. | | `localRuntime/` | `localRuntimeConnectionPool.ts` | Desktop-side client for the local `ade serve` daemon. Spawns or attaches to the machine socket, registers local projects with `projects.add`, dispatches local runtime actions with per-call timeouts where needed, polls runtime events, and installs the background service best-effort in packaged builds. | | `macosVm/` | `macosVmService.ts`, `rfbDirectClient.ts`, `credentialsStore.ts`, `runtimeBootstrap.ts`, `macosVmRecovery.ts` | Lane-tied macOS VM lifecycle and GUI control. `macosVmService.ts` uses Lume, stores VM records in `.ade/cache`, mounts direct lane roots when safe (otherwise a sanitized rsync mirror), and exposes screenshot/click/type/select through headless VNC or visible-window fallbacks. `credentialsStore.ts` keeps guest user credentials in the macOS Keychain (`/usr/bin/security`, service `ade-macos-vm-` / account `ade-cli`); renderers only see a summary. `runtimeBootstrap.ts` installs the in-guest ade-runtime over SSH+SCP with a five-phase progress signal (`ssh-probe`, `write-script`, `scp-script`, `run-script`, `verify-marker`). `macosVmRecovery.ts` is a standalone CLI cleanup path for stale records / lease / VNC credentials when the desktop surface cannot reach them. | -| `missions/` | `missionService.ts`, `missionPreflightService.ts`, `phaseEngine.ts` | Mission CRUD, preflight validation, phase lifecycle. | | `onboarding/` | `onboardingService.ts` | First-run flow, defaults detection, existing lane discovery. | | `opencode/` | `openCodeRuntime.ts`, `openCodeServerManager.ts`, `openCodeBinaryManager.ts`, `openCodeInventory.ts`, `openCodeModelCatalog.ts` | OpenCode server spawn, binary resolution, model discovery. | -| `orchestrator/` | See §4.5. | Deterministic mission runtime + intelligent coordinator. | | `processes/` | `processService.ts` | Managed-process lifecycle per lane, readiness probes, restart policies. | | `projects/` | `adeProjectService.ts`, `configReloadService.ts`, `projectService.ts`, `logIntegrityService.ts`, `recentProjectSummary.ts`, `projectBrowserService.ts`, `projectDetailService.ts` | Project detection + `.ade` repair/bootstrap, reload on config change, recent-project metadata. `projectBrowserService` is the in-app directory autocomplete used by the Command Palette project browser (typed-path completion, `.git` detection, home expansion, system-picker fallback); `projectDetailService` returns repo metadata (branch, dirty count, ahead/behind, last commit, README excerpt, language mix, lane count, last-opened) for the palette's preview pane. | | `prs/` | `prService.ts`, `prPollingService.ts`, `prSummaryService.ts`, `queueLandingService.ts`, `issueInventoryService.ts`, `prIssueResolver.ts`, `prRebaseResolver.ts`, `integrationPlanning.ts`, `integrationValidation.ts` | PR CRUD, polling (with per-PR `last_polled_at` cursor), AI summary cache keyed by `(prId, head_sha)`, stacked-queue landing, issue inventory, AI-assisted resolution, integration planning, and merge-into-existing-lane proposal adoption. | @@ -521,7 +500,7 @@ Startup sequencing: every background service goes through `scheduleBackgroundPro Project-init step timing goes through `measureProjectInitStep(step, task)` — a wrapper that logs `project.init_step { projectRoot, step, durationMs }` around each hot-path operation (`db_open`, `lane.ensure_primary`, `ade_rpc.socket_server_start`, `sync.initialize`, etc.) so cold-start latency shows up in the logs by phase. Sync-service initialization is scheduled through `scheduleBackgroundProjectTask` rather than awaited inline, gated by `ADE_ENABLE_SYNC_INIT`. -Shutdown pipeline: `main.ts` owns a single `requestAppShutdown({ reason, exitCode, fastKillFirst?, forceAfterMs? })` path driving a central state machine (`shutdownRequested` → `shutdownPromise` → `shutdownFinalized`). Hooks into `before-quit`, `window close`, `SIGINT`, `SIGTERM`, `process.exit`, `will-quit`, and `uncaughtException` all funnel through it. `runImmediateProcessCleanup()` disposes the orchestrator, automations, tests, processes, PTYs, agent chat runtimes, DB flush, and then calls `shutdownOpenCodeServers()`. A `forceAfterMs` timer (default 8 s, 5 s for signals/uncaught) hard-exits if cleanup hangs. User-initiated quit (main window close or `before-quit`) routes through `confirmQuitWarning()` — a modal dialog that explains that quitting will end agents and background processes owned by the desktop session, including OpenCode servers, terminal sessions, and test runs. +Shutdown pipeline: `main.ts` owns a single `requestAppShutdown({ reason, exitCode, fastKillFirst?, forceAfterMs? })` path driving a central state machine (`shutdownRequested` → `shutdownPromise` → `shutdownFinalized`). Hooks into `before-quit`, `window close`, `SIGINT`, `SIGTERM`, `process.exit`, `will-quit`, and `uncaughtException` all funnel through it. `runImmediateProcessCleanup()` disposes automations, tests, processes, PTYs, agent chat runtimes, DB flush, and then calls `shutdownOpenCodeServers()`. A `forceAfterMs` timer (default 8 s, 5 s for signals/uncaught) hard-exits if cleanup hangs. User-initiated quit (main window close or `before-quit`) routes through `confirmQuitWarning()` — a modal dialog that explains that quitting will end agents and background processes owned by the desktop session, including OpenCode servers, terminal sessions, and test runs. On startup the main process also invokes `recoverManagedOpenCodeOrphans({ force: true })` (see `services/opencode/openCodeServerManager.ts`) to reap previous-run OpenCode processes left behind after a crash. Orphan detection matches processes by the managed marker env (`ADE_OPENCODE_MANAGED=1`) and/or the shared XDG config root, and confirms orphaning either by dead owner PID (`ADE_OPENCODE_OWNER_PID`) or reparent-to-init. Each acquire of a shared OpenCode server also invokes `pruneIdleSharedEntries()` which compacts idle entries from older configs (`pool_compaction` reason). @@ -559,12 +538,10 @@ Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Brows - Per-project work-view state keyed by project root (`WorkProjectViewState`). Includes the right-edge Work sidebar fields `workSidebarOpen`, `workSidebarTab` (`"git" | "files" | "ios" | "app-control" | "browser"`), and `workSidebarWidthPct` (clamped 26–55) — persisted alongside the rest of the work-view state under `ade.workViewState.v1`. The sidebar consolidates lane-scoped tools that were previously split across separate floating panes; per-chat iOS / App Control drawers still exist on `AgentChatPane` but are suppressed when the chat is mounted as a Work tile so the sidebar owns those surfaces at lane scope. The `browser` tab is the only sidebar tab that is not lane-scoped — the built-in browser is one shared instance per app. - Project tab bookkeeping. `openProjectTabRoots: string[]` is the LRU-ordered list of project roots open in the window (mirrored to the main process via `ade.app.setWindowProjectTabs` so background services keep those projects warm); `projectInfoByRoot: Record` caches the `ProjectInfo` payload for tab favicons and offline tab rendering. `setProject` is the only path that mutates either map. - Stale-while-revalidate switch caches. `laneSelectionByProject` remembers the `{ laneId, sessionId }` selection per project root so switching tabs lands on the lane/chat the user last had open instead of "first lane". `laneCacheByProject` mirrors the last good `{ lanes, laneSnapshots }` per root; `switchProjectToPath` applies the cached entry immediately on switch (no spinner, no chat-pane unmount) and refreshes silently in the background. `sessionsCacheByProject` does the same for `useWorkSessions` so the chat tabs / terminal grid don't blank during a tab swap. All three caches are pruned to active + recent-projects when a project actually changes. -- Store-owned event subscriptions for high-frequency streams (e.g., missions). - `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`) subscribe to it and tear down any entries whose `projectRoot`/`projectRevision` no longer match, so PTYs never bleed between projects. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. Domain stores co-located with their pages follow the same factory + context pattern when they need per-page isolation: -- `useMissionsStore` (`components/missions/useMissionsStore.ts`) — mission list, selected mission, debounced event handling, store-owned timers. Exposes `createMissionsStore()`, `MissionsStoreProvider`, and `useMissionsStoreApi()`; `MissionsPage` instantiates its own store inside a `MissionsStoreProvider` so a missions surface in one project tab doesn't leak selection / chat-jump-target / prompt-inspector state into another. Components that previously called `useMissionsStore.getState()` now read through `useMissionsStoreApi()` to hit the scoped store. The companion `MissionActiveContext` carries the `active` prop down so `MissionsWorkspace`, `MissionsProductionGate`, `MissionHeader`, `MissionChatV2`, and `CompactUsageMeter` can gate their polling / event subscriptions on whether the missions surface is the foreground tab. - `chatDraftStore.ts` — draft messages per chat session. ### 7.3 Component organization @@ -583,7 +560,6 @@ graph/ # WorkspaceGraphPage (decomposed into nodes/edges/dialogs) prs/ # PR list/detail, stacked queue, shared/ history/ # operation timeline automations/ # rule list, pipeline builder -missions/ # MissionsPage + decomposed sub-modules cto/ # CTO page, identity editor, team panel, pipeline, shared/designTokens.ts onboarding/ # first-run flows settings/ # keybindings, agents, data, context, sync @@ -592,7 +568,7 @@ shared/ # MentionInput, shared interactive bits ui/ # pure presentation primitives ``` -Design tokens have been intentionally trimmed. The CTO design tokens at `apps/desktop/src/renderer/components/cto/shared/designTokens.ts` are the example style: a small set of Tailwind class constants (`cardCls`, `surfaceCardCls`, `shellBodyCls`, `inputCls`, `labelCls`, etc.) and a constrained accent palette (`ACCENT.purple/blue/green/pink/amber`). Lane design tokens live at `lanes/laneDesignTokens.ts` and are imported across missions/lanes/PRs/settings. +Design tokens have been intentionally trimmed. The CTO design tokens at `apps/desktop/src/renderer/components/cto/shared/designTokens.ts` are the example style: a small set of Tailwind class constants (`cardCls`, `surfaceCardCls`, `shellBodyCls`, `inputCls`, `labelCls`, etc.) and a constrained accent palette (`ACCENT.purple/blue/green/pink/amber`). Lane design tokens live at `lanes/laneDesignTokens.ts` and are imported across lanes/PRs/settings. ### 7.4 Layout patterns @@ -600,9 +576,9 @@ Design tokens have been intentionally trimmed. The CTO design tokens at `apps/de - `SplitPane` / resizable panels — structured 2/3-pane views. - Work view's grid mode is `PaneTilingLayout` seeded by `buildWorkSessionTilingTree(sessionIds)` (in `renderer/components/terminals/workSessionTiling.ts`); every session becomes a `FloatingPane` leaf with `grid-tile` chrome. - Project tab hosting: `App.tsx`'s `ProjectTabHost` mounts one persistent `ProjectSurface` per open project tab inside a single window. Each `ProjectSurface` owns its own zustand store instance (`createProjectAppStore(project)`), pre-hydrated with the project binding plus a copy of root-store user preferences (theme, terminal preferences, chat font, sound, density, etc.). User-preference setters point at the **root** store, so changes flow to one place and are then mirrored into every project store on the next `rootPrefs` change. A LRU sorts mounted surfaces and caps the warm-mounted set at `WARM_PROJECT_SURFACE_LIMIT = 8`; surfaces beyond that limit are dropped from the React tree (their store entry is GC'd) but the persisted lane/chat caches in the root store keep their data live so a re-mount is cheap. -- Per-project routing: each surface remembers its own route (`/work`, `/lanes`, `/files`, `/prs`, `/cto`, `/automations`, `/missions`, `/settings`, …) under `ade:project-route:` in `localStorage`. `ProjectTabHost` swaps which surface is `active` based on the foreground project tab, stashing the outgoing route and replaying the incoming surface's last route via `navigate(..., { replace: true })`. Inactive surfaces stay in the tree (`aria-hidden`, `inert`, absolutely positioned at `z-index: -1`, opacity 0, pointer-events none) so chats / terminals / live polling don't tear down on tab swap. +- Per-project routing: each surface remembers its own route (`/work`, `/lanes`, `/files`, `/prs`, `/cto`, `/automations`, `/settings`, …) under `ade:project-route:` in `localStorage`. `ProjectTabHost` swaps which surface is `active` based on the foreground project tab, stashing the outgoing route and replaying the incoming surface's last route via `navigate(..., { replace: true })`. Inactive surfaces stay in the tree (`aria-hidden`, `inert`, absolutely positioned at `z-index: -1`, opacity 0, pointer-events none) so chats / terminals / live polling don't tear down on tab swap. - Work-surface reveal: `ProjectRouteContent` keeps the `/work` route mounted lazily inside each project surface. When the surface itself becomes active **and** the route is a work route, it dispatches the `WORK_SURFACE_REVEALED_EVENT` window event so terminal tiles can clear their texture atlas, force-fit, and refocus. -- Page-level active gating: lazy feature pages (`LanesPage`, `FilesPage`, `WorkspaceGraphPage`, `PRsPage`, `ReviewPage`, `HistoryPage`, `AutomationsPage`, `AutomationsTemplatesPage`, `MissionsPage`, `CtoPage`, `SettingsPage`) accept an `active?: boolean` prop and gate every `useEffect` that fires IPC polling, event subscriptions, or initial data fetches behind it. Inactive surfaces in background project tabs render their last state but don't poll — the project's runtime is still alive, so the freshness is restored on the next refresh when the user returns. +- Page-level active gating: lazy feature pages (`LanesPage`, `FilesPage`, `WorkspaceGraphPage`, `PRsPage`, `ReviewPage`, `HistoryPage`, `AutomationsPage`, `AutomationsTemplatesPage`, `CtoPage`, `SettingsPage`) accept an `active?: boolean` prop and gate every `useEffect` that fires IPC polling, event subscriptions, or initial data fetches behind it. Inactive surfaces in background project tabs render their last state but don't poll — the project's runtime is still alive, so the freshness is restored on the next refresh when the user returns. - The desktop TopBar project tab strip resolves a per-project favicon via `window.ade.project.resolveIcon(rootPath)` and caches the result in a module-local `Map`. Tabs without an icon (or a missing project root) fall back to the `Folder` Phosphor glyph; the same component drives the loading-pulse animation when a tab is being switched into or closed. - Layout state persists to SQLite (`layout`, `tilingTree`, `graphState` domains via the `kv` table). @@ -627,10 +603,10 @@ Themes: six shipped themes (`e-paper`, `bloomberg`, `github`, `rainbow`, `sky`, ### 7.6 Renderer primitives -- `renderer/lib/dialogBus.ts` — tiny pub/sub that lets the onboarding tour engine (and anyone else) open/close dialogs by a stable id (`lanes.create`, `missions.create`, etc.) without prop-drilling. Dialogs subscribe by id; a `subscribeAll` channel exists for devtools. Default singleton export `dialogBus`. +- `renderer/lib/dialogBus.ts` — tiny pub/sub that lets the onboarding tour engine (and anyone else) open/close dialogs by a stable id (`lanes.create`, `settings.ai`, etc.) without prop-drilling. Dialogs subscribe by id; a `subscribeAll` channel exists for devtools. Default singleton export `dialogBus`. - `renderer/onboarding/waitForTarget.ts` — polls for a DOM target (ref or `data-onboarding-target`) with a visibility check so tour steps anchor reliably to async-mounted UI. - `renderer/onboarding/TourController.ts` — imperative driver for the onboarding tour state machine. -- `renderer/onboarding/docsLinks.ts` — typed registry of internal/public doc URLs (`docs.lanes`, `docs.missions`, …) that tour steps and the `HelpMenu` link to. +- `renderer/onboarding/docsLinks.ts` — typed registry of internal/public doc URLs (`docs.lanes`, `docs.cto`, …) that tour steps and the `HelpMenu` link to. - `renderer/components/onboarding/fx/*` — shared motion-FX primitives (`ActIntro`, `AnimatedField`, `Confetti`, `GhostCursor`, `MorphingTree`, `Spotlight`, `StaggeredText`, `TourIllustration`) with a `useReducedMotion` hook. Used by the 13-act first-session tutorial and per-tab tours. Related UI docs: [Terminals UI surfaces](./features/terminals-and-sessions/ui-surfaces.md), [Files and editor](./features/files-and-editor/README.md), and [Onboarding and settings](./features/onboarding-and-settings/README.md). @@ -776,7 +752,6 @@ ADE does not generate PRD or architecture bootstrap documents. Agent prompts tel | Narrative generation | `LaneExportStandard` (lane, bounded) | | Conflict proposal | `LaneExportLite` (lane) + `LaneExportLite` (peer, optional) + `ConflictExportStandard` | | PR description | `LaneExportStandard` with commit history | -| Mission planning | Mission-scoped data + direct repo inspection guidance | | Initial context (repo scan) | Targeted file/commit digests | --- @@ -787,11 +762,11 @@ ADE does not generate PRD or architecture bootstrap documents. Agent prompts tel Proof is **intentional**. Agents run computer use through whatever tool they already have — Claude's `computer_use`, Codex shell, a scripted browser, a headless Playwright run, a local screenshot. ADE stays out of that loop. When the agent reaches a checkpoint worth showing, it files an artifact through the broker (directly or via `ade proof capture` / `attach`), optionally with a caption. That record is what the drawer UI renders and what reviewers see. -The previous control-plane model — `ComputerUsePolicy` (`off`/`auto`/`enabled`, `allowLocalFallback`, `retainProof`, `preferredBackend`), per-phase `evidenceRequirements`, mission-preflight coverage gates, the passive `proofObserver` that auto-ingested from chat `tool_result` events, and the Settings > Computer Use panel — was removed. There is **one path** now: intentional ingest via the broker. +The previous control-plane model — `ComputerUsePolicy` (`off`/`auto`/`enabled`, `allowLocalFallback`, `retainProof`, `preferredBackend`), passive `proofObserver` ingestion from chat `tool_result` events, and the Settings > Computer Use panel — was removed. There is **one path** now: intentional ingest via the broker. ### 12.2 Broker and backends -`apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` is the ingest boundary. It accepts `ComputerUseArtifactInput[]` (path, remote URI, inline text, inline JSON), materializes on-disk sources into the project artifacts dir via `secureCopyFromDescriptor` (uses `O_NOFOLLOW` + atomic rename to resist symlink tricks), writes the canonical `computer_use_artifacts` row, and links to one or more owners (`lane`, `mission`, `orchestrator_run/_step/_attempt`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`). +`apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` is the ingest boundary. It accepts `ComputerUseArtifactInput[]` (path, remote URI, inline text, inline JSON), materializes on-disk sources into the project artifacts dir via `secureCopyFromDescriptor` (uses `O_NOFOLLOW` + atomic rename to resist symlink tricks), writes the canonical `computer_use_artifacts` row, and links to one or more owners (`lane`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`). Allowed import roots (trust boundary): `.ade/artifacts`, `.ade/tmp`, `os.tmpdir()`, `~/.agent-browser`. Other paths are rejected. @@ -809,7 +784,7 @@ Canonical proof kinds: `screenshot`, `video_recording`, `browser_trace`, `browse Canonical tables: - `computer_use_artifacts` — proof kind, backend name/style, source tool metadata, title/description, URI, storage kind, MIME type, review/workflow state, timestamps. -- `computer_use_artifact_links` — cross-domain ownership, so the same artifact can graduate from exploratory chat evidence to a mission artifact to a PR comment without losing provenance. +- `computer_use_artifact_links` — cross-domain ownership, so the same artifact can graduate from exploratory chat evidence to a PR comment without losing provenance. ### 12.4 IPC + UI @@ -820,7 +795,7 @@ Channels (under `ade.proof.*`, renamed from `ade.computerUse.*`): Renderer surfaces: -- `ChatComputerUsePanel` (drawer under the chat composer) and `MissionComputerUsePanel` / `MissionProofPanel` (mission detail Proof tab). +- `ChatComputerUsePanel` (drawer under the chat composer) and PR/automation proof surfaces. - Review actions (`accepted` / `needs_more` / `dismissed` / `published`) remain as first-class per-artifact actions. - Computer-use readiness moved into `IntegrationsSettingsSection` — the standalone `ComputerUseSection.tsx` is gone. @@ -840,7 +815,7 @@ The sync subsystem is **owned by the ADE runtime daemon** (`apps/ade-cli/src/ser ### 13.2 Device model -- **Host**: a runtime daemon on one reachable machine owns live execution side effects (agents, missions, PTYs, processes) for a given project. Stored in the synced `sync_cluster_state` singleton row (`brain_device_id` is the legacy internal column name; user-facing language is "host"). Transfer requires a clean preflight (no active missions, running turns, live PTYs, running processes). Paused missions, CTO history, and idle chats are durable and survive handoff. +- **Host**: a runtime daemon on one reachable machine owns live execution side effects (agents, PTYs, processes) for a given project. Stored in the synced `sync_cluster_state` singleton row (`brain_device_id` is the legacy internal column name; user-facing language is "host"). Transfer requires a clean preflight (no running turns, live PTYs, running processes). CTO history and idle chats are durable and survive handoff. - **Controllers**: other connected devices (phones always; a second desktop optionally). Controllers read synced state and send commands to the host runtime. - **Independent desktops**: a second Mac can run its own runtime daemon and work independently through git without joining an ADE sync session. The tracked `.ade/` scaffold/config layer makes a clone look like an ADE project immediately. @@ -951,7 +926,7 @@ Sharding is required because the desktop suite is large enough to be slow in a s - **Test locations**: colocated with source (`*.test.ts` / `*.test.tsx`) under `src/**`. - **Setup**: `apps/desktop/src/test/setup.ts` (browser/DOM mocks via `browserMock.ts`). - **Philosophy**: keep tests that carry real value; aggressively remove brittle UI/render tests; keep mutation + integration coverage solid. -- **Smoke tests**: `orchestratorSmoke.test.ts` for complex mock orchestration flows; `packagedRuntimeSmoke.test.ts` for packaged runtime. +- **Smoke tests**: `packagedRuntimeSmoke.test.ts` for packaged runtime. ### 14.4 Packaging (Electron Builder) @@ -979,7 +954,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): ### 14.5 Documentation - **Internal docs** (this directory + `docs/`) — for engineers and agents. Not published. -- **Public docs site** — Mintlify, configured in `docs.json` at repo root. Content lives alongside the repo (`introduction.mdx`, `quickstart.mdx`, `welcome.mdx`, `key-concepts.mdx`, plus subdirs `getting-started/`, `guides/`, `lanes/`, `chat/`, `missions/`, `cto/`, `pull-requests/`, `configuration/`, `tools/`, `computer-use/`, `automations/`, `ai-tools/`). Theme `maple`, brand primary `#7C3AED`. +- **Public docs site** — Mintlify, configured in `docs.json` at repo root. Content lives alongside the repo (`introduction.mdx`, `quickstart.mdx`, `welcome.mdx`, `key-concepts.mdx`, plus subdirs `getting-started/`, `guides/`, `lanes/`, `chat/`, `cto/`, `pull-requests/`, `configuration/`, `tools/`, `computer-use/`, `automations/`, `ai-tools/`). Theme `maple`, brand primary `#7C3AED`. - **Doc validation**: `scripts/validate-docs.mjs` runs in CI to catch broken links / structure drift. --- @@ -997,7 +972,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - **IPC tracing** — every handler emits `ipc.invoke.begin` / `ipc.invoke.done` / `ipc.invoke.failed` with call ID, channel, window ID, duration, summarized args. Mandatory for new handlers. - **Renderer lifecycle** — `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. Mandatory for new surfaces that introduce novel lifecycle transitions. - **Startup tasks** — `project.startup_task_enabled`, `project.startup_task_skipped`, `project.startup_task_begin`, `project.startup_task_done` with durations. -- **Usage tracking** — `usageTrackingService.ts` + `budgetCapService.ts` account for tokens and cost per provider/model/call-type; surfaced in Missions UI and the top-bar Usage popup (`HeaderUsageControl` → `UsageQuotaPanel` + collapsible `BudgetCapEditor`). +- **Usage tracking** — `usageTrackingService.ts` + `budgetCapService.ts` account for tokens and cost per provider/model/call-type; surfaced in the top-bar Usage popup (`HeaderUsageControl` → `UsageQuotaPanel` + collapsible `BudgetCapEditor`). - **Local perf runs** — `scripts/perf-launch.mjs` / `scripts/run-perf-scenario.mjs` launch ADE with a run id, feed renderer scenarios, and collect JSONL events plus `summary.json` under `~/.ade/perf-runs//`. This is local-only diagnostics, not external telemetry. - **No external telemetry** — ADE does not ship analytics to any cloud service. All telemetry is local. @@ -1005,7 +980,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - Every cleanup step is `try/catch` isolated — one failing service must not block shutdown. - IPC handlers return structured errors, never crash the renderer. -- Mission and CTO UI components use try/catch around async loads with `isLoading`/`error` state and retry actions. +- CTO and AI UI components use try/catch around async loads with `isLoading`/`error` state and retry actions. - Graceful degradation: when no provider is configured, AI surfaces show explanatory disabled state rather than spinning. - Explicit fallbacks: Linear sync skips when no credentials/workflows; Linear ingress stays dormant without config; trivial session summaries skip AI entirely. @@ -1036,7 +1011,6 @@ Post-packaging hardening (`apps/desktop/scripts/`): - Project dashboard / Run tab · [Project Home](./features/project-home/README.md) - Lanes and Git isolation · [Lanes](./features/lanes/README.md) - Agent chat · [Chat](./features/chat/README.md) -- Missions and orchestration · [Missions](./features/missions/README.md) - Pull requests and queues · [Pull Requests](./features/pull-requests/README.md) - Multi-device sync and iOS · [Sync and Multi-device](./features/sync-and-multi-device/README.md) - Terminal sessions and Work · [Terminals and Sessions](./features/terminals-and-sessions/README.md) diff --git a/docs/OPTIMIZATION_OPPORTUNITIES.md b/docs/OPTIMIZATION_OPPORTUNITIES.md index 5b83c69e0..8add5c499 100644 --- a/docs/OPTIMIZATION_OPPORTUNITIES.md +++ b/docs/OPTIMIZATION_OPPORTUNITIES.md @@ -11,42 +11,28 @@ Ground rules used to compile this list: ## HIGH (user-visible performance) -### 1. Replace polling with event listeners in preload `subscribeRunView` -- **Where**: `apps/desktop/src/preload/preload.ts` `subscribeRunView` helper. -- **Issue**: Uses 160 ms `setTimeout` polling to schedule refreshes after mission/runtime/thread events. -- **Fix**: Move `missionListener`/`runtimeListener`/`threadListener` to direct IPC event subscriptions. Drop the `scheduleRefresh` wrapper. -- **Risk**: Low (event infrastructure already exists). -- **Estimated gain**: 100–400 ms per mission-view interaction. - -### 2. Pause renderer watchdog interval when tab hidden +### 1. Pause renderer watchdog interval when tab hidden - **Where**: `apps/desktop/src/renderer/main.tsx` — `setInterval(..., 1000)` for `readRendererMemory` + `logRendererDebugEvent`. - **Issue**: Runs every 1 s regardless of `document.visibilityState`. - **Fix**: Add visibility-change listener; clear/restart interval on hidden/visible. - **Risk**: Low (observational code). - **Estimated gain**: 5–15% CPU reduction when backgrounded; improved battery. -### 3. Cache mission state JSON reads -- **Where**: `apps/desktop/src/main/services/orchestrator/orchestratorService.ts` — `loadMissionStateDocCounts` (readFileSync + JSON.parse on every call). -- **Issue**: Full JSON parse per call; can re-parse multi-MB docs repeatedly. -- **Fix**: In-memory cache keyed by `(projectRoot, runId)`. Invalidate on mission state mutations. -- **Risk**: Medium (must track all mutation paths). -- **Estimated gain**: 50–150 ms per access on large mission docs. - -### 4. Batch localStorage writes in `appStore` +### 2. Batch localStorage writes in `appStore` - **Where**: `apps/desktop/src/renderer/state/appStore.ts` — `debouncedPersistWorkViewState`, `persistSmartTooltips`, `persistTheme`. - **Issue**: Three separate debounce/write paths; JSON.stringify runs every 300 ms tick. - **Fix**: Combine into one debounce; change-detect via `structuredClone`. - **Risk**: Low. - **Estimated gain**: 30–80 ms per second of active use. -### 5. Split mega-components into memoized children +### 3. Split mega-components into memoized children - **Where**: `WorkspaceGraphPage.tsx` (4406 lines), `PrDetailPane.tsx` (3569), `AgentChatMessageList.tsx` (3175), `AgentChatPane.tsx` (3080), `IntegrationTab.tsx` (3022). - **Issue**: Single-file components re-render fully on any prop change. List items lack `memo`. - **Fix**: Extract stable list items into `React.memo`'d children. Move derived arrays into `useMemo`. - **Risk**: Medium (dep-array care required). - **Estimated gain**: 200–600 ms on route transitions; 150–400 ms per turn render. -### 6. Memoize intermediate arrays in `AgentChatMessageList` +### 4. Memoize intermediate arrays in `AgentChatMessageList` - **Where**: `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx`. - **Issue**: 23 `.map()` calls in render path; `selectedEventsForDisplay`, `navigationSuggestions`, `requestQuestions` recomputed every render. - **Fix**: Wrap in `useMemo`; memoize list-item children. @@ -57,49 +43,42 @@ Ground rules used to compile this list: ## MEDIUM (unnecessary work) -### 7. Collapse filter→map chains on step/status derivations +### 5. Collapse filter→map chains on step/status derivations - **Where**: `orchestratorService.ts:~4620`, `aiOrchestratorService.ts:~2424`, `coordinatorTools.ts:~394`. - **Issue**: `graph.steps.filter(s => s.status === "running").map(s => s.stepKey)` on every graph tick. - **Fix**: Single `reduce` or pre-computed status index. - **Risk**: Low. - **Estimated gain**: 20–60 ms per graph update (scales with step count). -### 8. Lazy-load non-essential preload IPC routes +### 6. Lazy-load non-essential preload IPC routes - **Where**: `apps/desktop/src/preload/preload.ts` (~550 exposed methods). - **Issue**: Entire IPC surface is eagerly constructed at renderer-bridge time. - **Fix**: Lazy-load onboarding/automations/cto bridges on first use. - **Risk**: Medium (renderer must tolerate late binding). - **Estimated gain**: 50–150 ms faster startup. -### 9. Parallelize / defer main-process service initialization +### 7. Parallelize / defer main-process service initialization - **Where**: `apps/desktop/src/main/main.ts`. - **Issue**: ~50 service constructors run in series on app startup. - **Fix**: Defer non-critical services (automations, cto, skillRegistry) to project-load or first-use. Parallelize independent init. - **Risk**: Medium (guarantee IPC handlers exist before renderer calls). - **Estimated gain**: 500–1500 ms faster project-open. -### 10. Combine `warmLaneStatusTimer` + `warmProviderModeTimer` +### 8. Combine `warmLaneStatusTimer` + `warmProviderModeTimer` - **Where**: `apps/desktop/src/renderer/state/appStore.ts` lane/provider warmup section. - **Issue**: Two independent 1200 ms and 1800 ms timers cascading refreshes. - **Fix**: Single `scheduleWarmup` with merged delay. - **Risk**: Low. - **Estimated gain**: Fewer redundant network requests; 50–100 ms per interaction. -### 11. Parse only needed fields in `loadMissionStateDocCounts` -- **Where**: `orchestratorService.ts` — full `JSON.parse` but only array lengths are read. -- **Issue**: Full object tree reified and discarded. -- **Fix**: Count via regex or streaming parser; or use cached result from #3. -- **Risk**: Low. -- **Estimated gain**: 10–30 ms per call. - -### 12. Audit event-listener cleanup +### 9. Audit event-listener cleanup - **Where**: `apps/desktop/src/renderer/components/ui/SmartTooltip.tsx:78`, renderer panels with `.on()` subscriptions, others. - **Issue**: Empty-deps cleanup effects may skip listener removal on unmount. - **Fix**: Explicit remove for every `.on()`; track via `Set` if needed. - **Risk**: Low. - **Estimated gain**: Prevents 50–200 MB leak over 8-hour sessions. -### 13. Hoist inline config objects to module scope +### 10. Hoist inline config objects to module scope - **Where**: `IntegrationTab.tsx` (`OutcomeDot` config), `AppShell.tsx` (`EMPTY_TERMINAL_ATTENTION`), others. - **Issue**: Object literals reconstructed every render. - **Fix**: Move to module scope or `useMemo` if deps exist. @@ -110,42 +89,42 @@ Ground rules used to compile this list: ## LOW (code quality & maintainability) -### 14. Narrow useEffect dependency arrays +### 11. Narrow useEffect dependency arrays - **Where**: Widespread across `renderer/components/`. - **Issue**: Deps reference entire prop objects when only 1–2 fields matter. - **Fix**: Use field selectors (`.length`, `[0]?.id`) in deps. - **Risk**: Low. - **Estimated gain**: 50–200 ms per parent prop change (scales with depth). -### 15. Apply `React.memo` to high-frequency list children -- **Where**: Missions tab (`AgentChannels.tsx`, `MissionSidebar.tsx`), Chat tab (`AgentChatMessageList.tsx`). +### 12. Apply `React.memo` to high-frequency list children +- **Where**: Chat tab (`AgentChatMessageList.tsx`) and other high-volume lists. - **Issue**: Only 33 `React.memo` usages across 143 files with `useMemo`/`useCallback`. - **Fix**: Wrap list items in `memo` with custom comparison where needed. - **Risk**: Low. - **Estimated gain**: 100–400 ms on 50+ item lists. -### 16. Move shared types out of service imports +### 13. Move shared types out of service imports - **Where**: `orchestratorService` types referenced directly in renderer. - **Issue**: Type-only re-parse; potential bundle bloat. - **Fix**: Centralize in `shared/orchestratorTypes.ts` (or existing `shared/types/`). - **Risk**: Low. - **Estimated gain**: 50–150 ms bundle parse. -### 17. Coalesce duplicate IPC invokes +### 14. Coalesce duplicate IPC invokes - **Where**: `ProcessService.ts:~712` (`Promise.all(ordered.map(id => startByDefinition(...)))`). - **Issue**: Parallel IPC without deduplication of identical calls. - **Fix**: Request-coalescing dispatcher. - **Risk**: Medium (central infrastructure change). - **Estimated gain**: 50–200 ms on multi-process starts. -### 19. Atomic `UserPreferences` store +### 15. Atomic `UserPreferences` store - **Where**: `appStore.ts` — `theme`, `terminalPreferences`, `smartTooltips` each persist separately. - **Issue**: Three writes where one would do. - **Fix**: Single atomic store + write. - **Risk**: Low. - **Estimated gain**: 20–40 ms on settings changes. -### 20. Compact JSON for machine-consumed files +### 16. Compact JSON for machine-consumed files - **Where**: `preload.ts:~452` and similar writers using `JSON.stringify(x, null, 2)`. - **Issue**: Pretty-print cost on every write. - **Fix**: Compact for machine files; pretty only on explicit human-export. @@ -157,16 +136,16 @@ Ground rules used to compile this list: ## Estimated total impact if all applied - Startup: 1–3 s faster project-open. -- Interactions: 200–800 ms faster on heavy pages (missions, PRs, graph). +- Interactions: 200–800 ms faster on heavy pages (PRs, graph, chat). - Process footprint: 50–200 MB smaller over long sessions. ## Highest ROI (pick these first) -1. #1 — replace preload polling (low risk, immediate gain). -2. #2 — pause renderer watchdog when hidden (trivial, big battery win). -3. #3 — cache mission state reads (needs invalidation care but high gain). -4. #5 — split mega-components on the hot paths (missions, chat list, PR detail). -5. #9 — defer non-critical service init (biggest startup gain). +1. #1 — pause renderer watchdog when hidden (trivial, big battery win). +2. #3 — split mega-components on the hot paths (chat list, PR detail, graph). +3. #7 — defer non-critical service init (biggest startup gain). +4. #2 — batch localStorage writes in `appStore`. +5. #9 — audit event-listener cleanup. Review risks before picking these up — several touch fragile boundaries (main.ts startup ordering, preload IPC surface). Run full test suite after any change. diff --git a/docs/PRD.md b/docs/PRD.md index ed5565e16..13043c5b1 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,6 +1,6 @@ # ADE — Product Requirements -ADE is a **per-machine local-first runtime daemon** for AI-assisted software engineering. The runtime owns projects, git-worktree lanes of work, multi-provider AI chat, multi-agent missions, a persistent CTO agent, pipeline automations, PR stacking, conflict simulation, computer-use proofs, and multi-device sync. Three first-party clients attach to it as peers: the **Electron desktop app** (multi-window, one window per project, optionally bound to a remote runtime over SSH), the **`ade code` terminal client**, and the **iOS app**. The same `ade` CLI is also used directly from any shell. +ADE is a **per-machine local-first runtime daemon** for AI-assisted software engineering. The runtime owns projects, git-worktree lanes of work, multi-provider AI chat, a persistent CTO agent, worker delegation, pipeline automations, PR stacking, conflict simulation, computer-use proofs, and multi-device sync. Three first-party clients attach to it as peers: the **Electron desktop app** (multi-window, one window per project, optionally bound to a remote runtime over SSH), the **`ade code` terminal client**, and the **iOS app**. The same `ade` CLI is also used directly from any shell. This doc is the entry point. Every major feature and concept is linked to its detailed breakdown in [`features/`](./features/). For how the pieces fit together, read [ARCHITECTURE.md](./ARCHITECTURE.md) next. @@ -17,7 +17,7 @@ The clients of that runtime are equal: - **iOS app** (`apps/ios/`) — SwiftUI controller; connects to the runtime over WebSocket. The phone never runs agents. - **SSH-attached desktop** — a desktop window pointed at a remote runtime is the same client as a local window; the runtime daemon is what differs. -The primary unit of work inside any project is a **lane**: an isolated git worktree + per-lane process pool + agent session. Many lanes run concurrently — each with its own chat, its own processes, its own PR. Lanes compose into **stacks** (dependency chains) and graduate into **missions** (multi-agent, multi-step orchestrated runs) when the work is bigger than a single session. +The primary unit of work inside any project is a **lane**: an isolated git worktree + per-lane process pool + agent session. Many lanes run concurrently — each with its own chat, its own processes, its own PR. Lanes compose into **stacks** (dependency chains) and can be delegated to CTO workers or automation rules when the work needs durable routing. Layered on top, all owned by the runtime: - **Agents** — chat, CTO operator, workers. Multi-provider (Anthropic, OpenAI, Claude Code CLI, Codex, OpenCode, Cursor). Tool-aware. CTO worker adapter types: `claude-local`, `codex-local`, `process`. @@ -39,7 +39,6 @@ ADE is the control plane. It does not execute browser automation or computer-use | Project | One repo entry in the runtime's project registry. Identified by stable hash of root path; addressed in the multi-project RPC by `projectId`. | [remote-runtime/README.md](./features/remote-runtime/README.md) | | Lane | Isolated git worktree + per-lane process pool + agent session for one task. | [lanes/README.md](./features/lanes/README.md) | | Stack | Dependency chain of lanes → stacked PRs. | [lanes/stacking.md](./features/lanes/stacking.md) | -| Mission | Multi-step orchestrated run with a coordinator agent, sub-workers, validation gates, and a result lane. | [missions/README.md](./features/missions/README.md) | | Agent | Typed persona with identity, tool tier, budget, and session log. CTO + workers + chat agents. | [agents/README.md](./features/agents/README.md) | | Worktree | Git clone dir under `.ade/worktrees//`, one per lane. | [lanes/worktree-isolation.md](./features/lanes/worktree-isolation.md) | | Lane runtime | Per-lane process pool + env + ports + proxy + diagnostics. | [lanes/runtime.md](./features/lanes/runtime.md) | @@ -66,24 +65,23 @@ ADE is the control plane. It does not execute browser automation or computer-use - [**Agents**](./features/agents/README.md) — Three surfaces: chat, CTO operator, workers. Identity, capability modes, tool tiers, heartbeats. - [**Chat**](./features/chat/README.md) — Multi-provider, streaming, tool-aware. Transcript and turns, tool system (universal/workflow/coordinator), agent routing, composer + derived panels, and parallel multi-model lane launch. Terminal client: [ADE Code](./features/ade-code/README.md). -- [**History**](./features/history/README.md) — Two surfaces sharing one page: a GitKraken-style commit graph for the focused lane (per-commit branch/lane/tag/cherry-pick/revert/reset and lane-level head-change undo+redo), and a unified activity feed that merges operations with chat sessions, missions, CTO sessions, and worker runs. Every recorded service follows the same `runTrackedOperation` pattern. +- [**History**](./features/history/README.md) — Two surfaces sharing one page: a GitKraken-style commit graph for the focused lane (per-commit branch/lane/tag/cherry-pick/revert/reset and lane-level head-change undo+redo), and a unified activity feed that merges operations with chat sessions, CTO sessions, and worker runs. Every recorded service follows the same `runTrackedOperation` pattern. -### Orchestration +### Automation and CTO -- [**Missions**](./features/missions/README.md) — Coordinator agent, delegation graph, validation gates (19 VAL-XXX assertions), result-lane closeout, worker fan-out. -- [**Automations**](./features/automations/README.md) — Rule triggers (time, action, webhook) → three execution surfaces (mission, agent-session, built-in). Confidence + verification + human review. +- [**Automations**](./features/automations/README.md) — Rule triggers (time, action, webhook) → agent-session and built-in execution surfaces. Confidence + verification + human review. - [**CTO**](./features/cto/README.md) — Persistent project-level AI operator with identity, context continuity, Linear workflows, pipeline builder, and worker team. ### Workspace surfaces -- [**Terminals and Sessions**](./features/terminals-and-sessions/README.md) — PTY, session, managed-process, and lane-tied macOS VM services. Multi-run process lifecycle keyed by `runId`, AI-title pipeline, lazy resume-target hydration, stale reconciliation, VM-backed GUI validation. +- [**Terminals and Sessions**](./features/terminals-and-sessions/README.md) — PTY, session, managed-process, and lane-tied macOS VM services. Process lifecycle tracking, AI-title pipeline, lazy resume-target hydration, stale reconciliation, VM-backed GUI validation. - [**Files and Editor**](./features/files-and-editor/README.md) — Atomic writes, ref-counted chokidar watcher, file search index, Monaco surfaces (edit/diff/conflict), preload trust boundary. - [**Project Home**](./features/project-home/README.md) — Combined welcome + per-lane runtime dashboard. Loads lane-independent metadata vs lane runtime separately. - [**Onboarding and Settings**](./features/onboarding-and-settings/README.md) — First-run wizard (stack detection, suggested config, import), 9-tab settings, configuration schema with trust model. ### Integrations -- [**Linear Integration**](./features/linear-integration/README.md) — Webhook + relay + reconciliation. Workflow presets, target types (mission/session/worker/PR), bidirectional sync. +- [**Linear Integration**](./features/linear-integration/README.md) — Webhook + relay + reconciliation. Workflow presets, target types (session/worker/PR), bidirectional sync. - [**Computer Use**](./features/computer-use/README.md) — Intentional proof capture plus active App Control and macOS VM bridges. Canonical artifact model, ownership-linked storage. - [**iOS Simulator**](./features/ios-simulator/README.md) — Chat-side macOS-only drawer that builds, launches, mirrors, inspects, and controls a booted iOS Simulator. ADEInspector publishes per-frame SwiftUI element metadata so taps become source-anchored chat context. - [**Sync and Multi-Device**](./features/sync-and-multi-device/README.md) — cr-sqlite CRDT (desktop native ext, iOS pure-SQL emulation). Host/controller model. WebSocket envelope. Remote commands. @@ -126,7 +124,6 @@ Fragile areas flagged across the docs (read docs before editing): - PTY / sessions / processes services — rewritten this branch. - OAuth redirect service — complex three-state machine with HMAC signing. - Chat transcript render pipeline — two-layer event→state→render path. -- Mission coordinator delegation — 19 VAL-XXX behavioral invariants. --- diff --git a/docs/README.md b/docs/README.md index 8655b38b2..74877c787 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,6 @@ docs/ ├── ios-simulator/ # chat-side iOS Simulator drawer + ADEInspector ├── lanes/ # worktree isolation, stacking, runtime, OAuth ├── linear-integration/ # dispatch, sync, workflow presets - ├── missions/ # orchestration, validation gates, workers ├── onboarding-and-settings/ # first-run, schema, settings tabs ├── project-home/ # welcome + per-lane dashboard ├── pull-requests/ # stacking, queue, conflict simulation @@ -54,6 +53,6 @@ docs/ ## Relationship to the public (Mintlify) docs -`docs.json` at the repo root configures the public-facing Mintlify docs site (`.mdx` files under `./chat/`, `./tools/`, `./missions/`, etc.). That site is user-facing and separate. +`docs.json` at the repo root configures the public-facing Mintlify docs site (`.mdx` files under `./chat/`, `./tools/`, `./cto/`, etc.). That site is user-facing and separate. **This folder (`docs/`) is internal-only** — for engineers and AI agents working on ADE itself. diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 581714733..9976f4cb8 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -222,7 +222,6 @@ Several slash commands forward to a desktop route when issued from `ade code`: /computer-use -> /proof /ios, /ios-sim -> /ios-sim /macos-vm -> /vm -/mission, /missions -> /missions /pencil -> /pencil /proof -> /proof ``` diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 1c4bf7227..5dbb7137f 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -16,12 +16,12 @@ those surfaces. | `apps/desktop/src/main/services/cto/workerBudgetService.ts` | Monthly budget tracking. | | `apps/desktop/src/main/services/cto/flowPolicyService.ts` | Worker flow policies. | | `apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts` | Adapter lifecycle for supported worker adapter types. | -| `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only tools for chat spawning, mission control, worker management, and Linear dispatch. | +| `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only tools for chat spawning, worker management, and Linear dispatch. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | | `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser (`browser panel`, `browser open [--no-panel]`, `browser new-tab --background`, `browser switch`, `browser close`, plus selection / inspect commands). `ade chat create --provider codex --model --fast` opts a new Codex session into the fast service tier; `ade shell start --lane --chat-session ` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. | | `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for worker CLI launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. Owns the install-target path resolution and the optional shell-rc PATH append. | -| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, naming the bundled ADE skills, using socket-backed live surfaces, registering proof, and cleaning up started processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, CTO/mission workers, and mobile-started runtime work. | +| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, naming the bundled ADE skills, using socket-backed live surfaces, registering proof, and cleaning up started processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, CTO workers, and mobile-started runtime work. | | `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves and formats Agent Skill roots injected into prompts and CLI environments: lane/current-working-directory ancestors, user homes, inherited `ADE_AGENT_SKILLS_DIRS`, packaged ADE resources, and source fallbacks across `.claude`, `.agents`, `.ade`, and `.codex` skill directories. | | `apps/desktop/src/shared/ctoPersonalityPresets.ts` | CTO personality overlays. | | `apps/desktop/src/shared/types/agents.ts` | Worker identity, role, adapter, and runtime types. | @@ -125,17 +125,16 @@ is exactly `"claude-local" | "codex-local" | "process"`. ## Tool access tiers -| Tier | CTO | Worker | Regular chat | Coordinator | -|---|:-:|:-:|:-:|:-:| -| Universal (read, write, bash, web, todo) | yes | yes | yes | yes | -| Workflow (createLane, createPR, captureScreenshot, reportCompletion, PR issue resolution) | yes | no | yes | no | -| CTO operator (spawnChat, missions, worker management, Linear) | yes | no | no | no | -| Coordinator (spawn_worker, skip_step, complete_mission, etc.) | no | no | no | yes | -| Linear tools | yes (when connected) | no | no | no | +| Tier | CTO | Worker | Regular chat | +|---|:-:|:-:|:-:| +| Universal (read, write, bash, web, todo) | yes | yes | yes | +| Workflow (createLane, createPR, captureScreenshot, reportCompletion, PR issue resolution) | yes | no | yes | +| CTO operator (spawnChat, worker management, Linear) | yes | no | no | +| Linear tools | yes (when connected) | no | no | Standalone chat sessions connected through ADE CLI with no -mission/run/step/attempt context have coordinator tools hidden from tool -listing and execution at the ADE CLI server boundary. +worker context have elevated tools hidden from tool listing and +execution at the ADE CLI server boundary. ## Prompt composition @@ -154,8 +153,8 @@ identity/context prefixes: `workerHeartbeatService.ts` schedules worker activations. The runtime resolves the worker identity, adapter config, and current lane/project -context, then dispatches through the orchestrator or spawns a chat -session via the CTO's `spawnChat` tool. +context, then spawns a chat session via the CTO's `spawnChat` tool or +the worker adapter runtime. ## Session logs diff --git a/docs/features/agents/tool-registration.md b/docs/features/agents/tool-registration.md index 554414bea..f94404e47 100644 --- a/docs/features/agents/tool-registration.md +++ b/docs/features/agents/tool-registration.md @@ -16,7 +16,6 @@ filtering before exposing the final list. | `apps/ade-cli/src/jsonrpc.ts` | JSON-RPC server and socket transport helpers. | | `apps/desktop/src/main/main.ts` | Creates the per-project ADE RPC socket server and binds `createAdeRpcRequestHandler`. | | `apps/desktop/src/main/services/ai/tools/` | In-process tool implementations (universal, workflow, CTO operator, Linear). | -| `apps/desktop/src/main/services/orchestrator/coordinatorTools.ts` | Coordinator tool set for the mission orchestrator. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | External CLI detection (Claude Code, Codex, Cursor, Aider, Continue). | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side CLI install / status / uninstall. Resolves the launcher target (`$HOME/.local/bin/ade` on POSIX, `%LOCALAPPDATA%\ADE\bin\ade.cmd` on Windows) and, on POSIX install, appends a marked `export PATH=...` block to the user's shell rc when the install dir isn't already on `$PATH`. | | `apps/desktop/src/shared/adeCliGuidance.ts` | ADE guidance builders injected into agent system prompts and inline CLI preambles. Tells the agent how to find `ade` (PATH → `$ADE_CLI_PATH` → `$ADE_CLI_BIN_DIR/ade` → `node apps/ade-cli/dist/cli.cjs ...`), which bundled ADE skills exist, how Agent Skills are shaped (`/SKILL.md` plus optional `references/`, `scripts/`, `assets/`), which ADE-hosted surfaces receive the guidance, to try `ade doctor` / typed commands / `ade actions list` before reporting an ADE task as blocked, and to track and clean up stale or finished processes it starts. | @@ -96,8 +95,8 @@ ADE identity now flows through environment variables and CLI flags: - The desktop app sets ADE context env vars when it launches managed shells or agents. -- The CLI reads `ADE_CHAT_SESSION_ID`, `ADE_MISSION_ID`, `ADE_RUN_ID`, - `ADE_STEP_ID`, `ADE_ATTEMPT_ID`, `ADE_OWNER_ID`, and +- The CLI reads `ADE_CHAT_SESSION_ID`, `ADE_RUN_ID`, + `ADE_OWNER_ID`, and `ADE_DEFAULT_ROLE`. - The private RPC handler merges those values into its caller context before action filtering. @@ -115,7 +114,6 @@ await connection.request("ade/initialize", { caller: callerCtx }); Roles: - `cto` -- CTO session. Gets CTO operator + Linear tools. -- `orchestrator` -- Mission coordinator. Gets coordinator tools. - `agent` -- Worker agent. Gets agent-visible coordinator subset. - `external` -- External callers. Gets only the base action set. - `evaluator` -- Evaluation runs. @@ -150,31 +148,30 @@ to detect stale daemons: `listAdeActionsForSession` builds the visible action list: -```ts -async function listAdeActionsForSession(runtime, session): Promise { - const callerCtx = await resolveEffectiveCallerContext(runtime, session); - const baseActions = listBaseAdeActions(runtime); - const coordinatorActions = callerCtx.role === "orchestrator" - ? listCoordinatorActions(runtime) - : listAgentCoordinatorActions(runtime, callerCtx); - return filterActionsForCaller([...baseActions, ...coordinatorActions], callerCtx); -} -``` +`listToolSpecsForSession` builds the visible action list by resolving +the caller context and then branching on role: + +- `cto` — base tools + CTO operator tools + Linear sync tools. +- `agent`, `external`, `orchestrator`, `evaluator` — base tools only. + +A visibility filter removes computer-use and macOS VM tools when those +backends are unavailable or when the caller lacks local-computer-use +permission. The final `.filter(...)` applies standalone-chat restrictions: if the -session has `chatSessionId` but no mission/run/step/attempt context, -`STANDALONE_CHAT_HIDDEN_TOOL_NAMES` (including `spawn_agent` and -coordinator tools) are stripped from the list. +session has `chatSessionId` but no worker context, +`STANDALONE_CHAT_HIDDEN_TOOL_NAMES` (`spawn_agent`) is stripped from +the list. ### Role-to-toolset summary -| Role | Base tools | Coordinator access | +| Role | Base tools | Elevated access | |---|---|---| | `external` | Yes | No | -| `agent` | Yes | Agent-visible subset (tools with run/step/attempt context) | -| `cto` | Yes | No; has CTO operator + Linear sync instead | -| `orchestrator` | Yes | Full coordinator tool set | -| `evaluator` | Yes (limited; case-by-case) | No | +| `agent` | Yes | No | +| `cto` | Yes | CTO operator + Linear sync tools | +| `orchestrator` | Yes | No (base tools only) | +| `evaluator` | Yes | No | ## Rate limits @@ -258,7 +255,7 @@ and bundled ADE resources appear before inherited environment, packaged app, and source-fallback roots. The same full root list is joined into `ADE_AGENT_SKILLS_DIRS` for ADE-launched CLI sessions, headless worker launches, Work-tab CLI launches, ADE Code/TUI -sessions, CTO/mission worker prompts, and mobile-started work that +sessions, CTO worker prompts, and mobile-started work that runs through ADE's runtime. The guidance tells the agent that `ade` *should* be available, and diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 65995c313..d83c11352 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -1,6 +1,6 @@ # Automations -Automations are rule-based background workflows. Each rule has a trigger, a target execution surface, a prompt/mission template, an optional tool palette, an optional output contract, and guardrails. Automations sit between the CTO (heavy, stateful, chat-driven) and raw cron (deterministic, no AI). The execution surface choice is the key control point. +Automations are rule-based background workflows. Each rule has a trigger, a target execution surface, a prompt template or action chain, an optional tool palette, an optional output contract, and guardrails. Automations sit between the CTO (heavy, stateful, chat-driven) and raw cron (deterministic, no AI). The execution surface choice is the key control point. Automations never duplicate Linear issue intake — the CTO owns that. Automations can consume Linear as context or write to it as an action, but the canonical intake and routing logic lives in the CTO/Linear services hosted by the runtime daemon. @@ -16,7 +16,7 @@ Caveat: GitHub-polling and webhook ingress only work on a runtime that can reach These services are loaded by the runtime daemon's project scope (and by the desktop main process when it hosts a local project) — the path reflects the source tree, not where the code "runs". -- `automationService.ts` — main service. Rule CRUD, execution dispatch (`mission`, `agent-session`, `built-in`), cron scheduling (via `node-cron`), file-change watching (via `chokidar`), queue management, run history, confidence scoring, billing codes, ingress cursor storage. +- `automationService.ts` — main service. Rule CRUD, execution dispatch (`agent-session`, `built-in`), cron scheduling (via `node-cron`), file-change watching (via `chokidar`), queue management, run history, confidence scoring, billing codes, ingress cursor storage. - `automationPlannerService.ts` — natural-language rule authoring. `parseNaturalLanguage`, `validateDraft`, `saveDraft`, `simulate`. Runs a planner subprocess (Claude or Codex) to turn a free-text brief into an `AutomationRuleDraft`. - `automationIngressService.ts` — HTTP webhook ingress (GitHub, custom webhooks) and polling-relay ingress (GitHub relay API). Signature verification for webhooks. `AutomationIngressEventRecord` is the normalized event shape. - `githubPollingService.ts` — direct GitHub REST polling for the origin repo plus `extraRepos`. Diffs per-poll snapshots of issues/PRs/comments to emit `github.issue_*` and `github.pr_*` trigger events without requiring a webhook or relay. Cursor format is `=|=` to support multi-repo state in a single stored string; see `readCursor`/`writeCursor` for the legacy-compat parser. @@ -24,7 +24,7 @@ These services are loaded by the runtime daemon's project scope (and by the desk ### ADE Actions registry -- `apps/desktop/src/main/services/adeActions/registry.ts` — curated allowlist of `(domain, action)` pairs exposed to automation rules as the `ade-action` action type. Each domain maps to a main-process service (`lane`, `git`, `pr`, `issue`, `chat`, `mission`, `linear_*`, `file`, `pty`, etc.); the allowlist keeps the surface deterministic and audit-able. `listAllowedAdeActionNames` and `isAllowedAdeAction` gate runtime dispatch. +- `apps/desktop/src/main/services/adeActions/registry.ts` — curated allowlist of `(domain, action)` pairs exposed to automation rules as the `ade-action` action type. Each domain maps to a main-process service (`lane`, `git`, `pr`, `issue`, `chat`, `linear_*`, `file`, `pty`, etc.); the allowlist keeps the surface deterministic and audit-able. `listAllowedAdeActionNames` and `isAllowedAdeAction` gate runtime dispatch. ### Renderer @@ -54,12 +54,11 @@ Each `AutomationRule` carries: - `id`, `name`, `description`, `enabled`. - `triggers` — one or more trigger descriptors (see `triggers-and-actions.md`). Normalized to a single primary trigger for legacy compatibility. - `execution` — which surface launches. `AutomationExecution`: - - `{ kind: "mission", targetLaneId?, mission? }` — launches the full mission runtime. - `{ kind: "agent-session", targetLaneId?, session? }` — launches a scoped AI chat thread, recorded as an automation-only chat. `session` carries optional `title`, `reasoningEffort`, and `codexFastMode` (boolean); `codexFastMode` is forwarded to the chat service only when the resolved provider is Codex and the model supports fast mode, so it is safe to set on a rule that may later switch models. - `{ kind: "built-in", targetLaneId?, builtIn: { actions: [...] } }` — runs ADE-native deterministic actions (`AutomationAction[]`). - `executor` — always `{ mode: "automation-bot" }` (the automation system identifies itself that way in logs). - `reviewProfile` — `quick` | `incremental` | `full` | `security` | `release-risk` | `cross-repo-contract`. Drives confidence base and output expectations. -- `toolPalette` — explicit tool family list (`repo`, `git`, `tests`, `github`, `linear`, `browser`, `mission`). +- `toolPalette` — explicit tool family list (`repo`, `git`, `tests`, `github`, `linear`, `browser`). - `contextSources` — e.g. recent PRs or configured project context sources. - `guardrails` — `confidenceThreshold`, `maxDurationMin`, `requireHuman`, path/lane allowlists (see `guardrails.md`). - `outputs.disposition` — `comment-only` | `open-task` | `open-lane` | `prepare-patch` | `open-pr-draft`. @@ -88,23 +87,14 @@ Best for lightweight autonomous text-work: reviews, audits, short summaries, sta - Appears in Automations > History as a thread. - Minimal orchestration overhead — no planner, no run-graph, no worker pool. -### mission - -Best for code-affecting or multi-step tasks. - -- Launches through `aiOrchestratorService.startMission`. -- Gets the full mission runtime: planner, phases, worker pool, interventions, validation, result-lane closeout. -- Appears in the Missions UI (not Automations > History) because mission runs already have their own surface. -- Budget telemetry respects the rule's `billingCode`. - ### built-in Best for deterministic ADE operations. - Runs a sequence of `AutomationAction` steps with typed input/output. -- `AutomationActionType` values: `create-lane` (spawns a new lane and threads it into the rest of the chain), `run-command` (shell), `run-tests`, `predict-conflicts`, `launch-mission`, `agent-session` (embedded agent step), `ade-action` (see below). +- `AutomationActionType` values: `create-lane` (spawns a new lane and threads it into the rest of the chain), `run-command` (shell), `run-tests`, `predict-conflicts`, `agent-session` (embedded agent step), `ade-action` (see below). - Each action may override `targetLaneId` for that step alone; `agent-session` actions additionally accept `modelConfig` and `permissionConfig` overrides that layer on top of the rule's defaults (allowed-tool lists are merged, not replaced). See `triggers-and-actions.md` for the override resolution order. -- No separate mission thread. +- No separate worker thread. - Low overhead; sandboxed to the target lane's worktree via `validateAutomationCwd` and `resolvePathWithinRoot`. The `ade-action` action type dispatches directly into a main-process domain service through the ADE Actions registry (`apps/desktop/src/main/services/adeActions/registry.ts`). `RunAdeActionConfig` points at a `domain` + `action` on the allowlist (e.g. `pr.addComment`, `linear_sync.runSyncNow`, `issue.close`), with `args` that may embed `{{trigger.*}}` placeholders resolved from the trigger context at dispatch time, or an explicit `resolvers` map for the same. This gives built-in rules typed access to ADE services without writing a shell command or a bespoke tool. @@ -153,23 +143,22 @@ The queue dashboard renders severity summaries and suggested actions so operator Automations route outputs based on `outputs.disposition`: -- `comment-only` — write a comment to the mission or PR. +- `comment-only` — write a comment to the automation log or PR. - `open-pr` — open a draft PR from the target lane. - `linear-comment` — post a Linear comment (uses CTO's Linear client). - `in-app-notification` — push a desktop notification. - `evidence-only` — leave the run record; no external output. -`createArtifact: true` produces a `MissionArtifact` even for non-mission runs so the evidence is indexable. `notificationChannel` lets a rule override the default channel. +`createArtifact: true` records proof evidence for indexing. `notificationChannel` lets a rule override the default channel. ## Budget policy -- Budget caps come from the header Usage popup → Automation guardrails (shared with Missions). Rule-level caps via `guardrails.maxDurationMin` prevent runaway runs. +- Budget caps come from the header Usage popup → Automation guardrails. Rule-level caps via `guardrails.maxDurationMin` prevent runaway runs. - Usage telemetry respects `billingCode` so operators can slice spend per rule. ## Boundaries - **CTO owns Linear intake.** Automations cannot define `linear.issue_created` intake logic that competes with CTO workflows. Automations can trigger on Linear events for their own context, but the CTO's `linearDispatcherService` is the canonical dispatch path for Linear issues. -- **Mission runtime is the only mission surface.** Automations don't re-implement mission planning — `kind: "mission"` launches the real runtime. - **Built-in actions are deterministic.** They should not wrap an AI call. Use `agent-session` for AI-driven logic. ## Gotchas @@ -182,12 +171,9 @@ Automations route outputs based on `outputs.disposition`: - **Webhook secret verification is timing-safe.** Don't refactor `safeCompareSignature` into a plain string compare. - **Relay polling must respect the access token ref.** `automations.githubRelay.accessToken` is an env ref; resolve via `automationSecretService`, never hard-coded. - **Confidence threshold is `0.65` baseline.** Rules that explicitly raise the threshold penalize confidence proportionally — document this in rule descriptions so operators understand scoring. -- **Mission execution does not receive the automation's tool palette directly.** The mission runtime has its own permission model; set the `execution.mission.permissions` field (if used) rather than hoping the top-level tool palette is inherited. - ## Cross-links - `triggers-and-actions.md` — full trigger and action surface. - `guardrails.md` — approval gates, safety boundaries, verification modes. - `../cto/linear-integration.md` — the CTO owns Linear intake; automations do not duplicate it. -- `../missions/README.md` — mission execution surface details. -- `../computer-use/README.md` — automations can request computer-use proof via the mission surface. +- `../computer-use/README.md` — automations can request computer-use proof. diff --git a/docs/features/automations/guardrails.md b/docs/features/automations/guardrails.md index bf4d64968..6f4caec0f 100644 --- a/docs/features/automations/guardrails.md +++ b/docs/features/automations/guardrails.md @@ -81,7 +81,7 @@ This path is used for "what would this rule do?" dry runs — nothing persists e Status mapping: -- Mission and worker-backed runs map runtime state into the shared run statuses above. +- Worker-backed runs map runtime state into the shared run statuses above. - Legacy `needs_review` rows are normalized to `succeeded`; publish review now lives in queue status and verification metadata. The key invariant: when a rule has `verifyBeforePublish`, completion keeps the run in the queue through `verification-required` rather than inventing a separate run status. @@ -103,11 +103,11 @@ Publish is only allowed when: 3. `guardrails.requireHuman` is not set, or a reviewer has approved. 4. The disposition-specific path is available (e.g. Linear credentials exist for `linear-comment`). -`PUBLISH_CAPABLE_TOOL_FAMILIES` (`github`, `linear`, `browser`, `external-cli`) is the set of families that can publish — built-in palettes like `repo`, `git`, `tests`, `mission` cannot publish externally regardless of disposition. +`PUBLISH_CAPABLE_TOOL_FAMILIES` (`github`, `linear`, `browser`, `external-cli`) is the set of families that can publish — built-in palettes like `repo`, `git`, and `tests` cannot publish externally regardless of disposition. ## Human review -Review gating creates an intervention via the mission runtime (for `kind: "mission"`) or a queue entry (for `agent-session` and `built-in`). The operator: +Review gating creates a queue entry for `agent-session` and `built-in` runs. The operator: - Approves -> run transitions to `succeeded`, publish proceeds. - Rejects -> run transitions to `failed` or `cancelled` depending on the rejection reason. @@ -133,11 +133,11 @@ Built-in shell actions must stay within the allowed workdirs: - `validateAutomationCwd(baseCwd, cwdRaw)` resolves the requested cwd against the base and rejects anything outside. - Paths are resolved with `resolvePathWithinRoot` which handles symlink-resolution and rejects traversal. -Mission-execution rules inherit the mission runtime's sandbox (`WorkerSandboxConfig`). Agent-session rules run inside the ADE chat service's permission model (per-session allowed tools). +Agent-session rules run inside the ADE chat service's permission model (per-session allowed tools). Built-in shell actions use the cwd and lane allowlists above. ## Budget caps -Shared with Missions via the top-bar Usage popup. The popup (`HeaderUsageControl`, `UsageQuotaPanel`) renders the live provider quotas plus a collapsible Automation guardrails section that mounts `BudgetCapEditor`. Supporting widgets (`CostSummaryCard`, `UsageMeter`, `UsagePacingBadge`) still live under `apps/desktop/src/renderer/components/settings/` but are reused from the popup. Settings no longer has a Usage tab; the Automations page continues to focus on rules, history, and ingress. +Shared via the top-bar Usage popup. The popup (`HeaderUsageControl`, `UsageQuotaPanel`) renders the live provider quotas plus a collapsible Automation guardrails section that mounts `BudgetCapEditor`. Supporting widgets (`CostSummaryCard`, `UsageMeter`, `UsagePacingBadge`) still live under `apps/desktop/src/renderer/components/settings/` but are reused from the popup. Settings no longer has a Usage tab; the Automations page continues to focus on rules, history, and ingress. Automations also support rule-level caps: @@ -152,7 +152,7 @@ Every run writes: - Run row with executor, status, queue status, confidence, spend, trigger metadata, verification flag. - Action rows for each step with status and output. - Ingress event row (for webhook/relay triggers) with raw payload. -- Mission artifacts (when `outputs.createArtifact` is true) for cross-surface indexing. +- Proof artifacts (when `outputs.createArtifact` is true) for cross-surface indexing. - Procedure feedback (`AutomationProcedureFeedback`) — operator ratings and notes that feed back into the procedural learning service. ## Gotchas @@ -169,4 +169,3 @@ Every run writes: - `README.md` — rule structure and execution surfaces. - `triggers-and-actions.md` — trigger surface and built-in action catalog. - `../cto/workers.md` — same env-ref secret policy. -- `../missions/README.md` — mission runtime enforces its own sandboxing for mission-execution automations. diff --git a/docs/features/automations/triggers-and-actions.md b/docs/features/automations/triggers-and-actions.md index 05956c03f..73ad170dd 100644 --- a/docs/features/automations/triggers-and-actions.md +++ b/docs/features/automations/triggers-and-actions.md @@ -1,6 +1,6 @@ # Triggers and Actions -The complete surface of triggers the automation runtime listens for, and the actions available in `built-in` execution. For execution surfaces (`mission`, `agent-session`, `built-in`) and rule structure, see the `README.md`. +The complete surface of triggers the automation runtime listens for, and the actions available in `built-in` execution. For execution surfaces (`agent-session`, `built-in`) and rule structure, see the `README.md`. ## Source file map @@ -118,7 +118,6 @@ Label normalization helper `normalizeLabels` accepts either string arrays or obj - `github` -> `Bash`, `bash`, `ade.github__get_pull_request`, `ade.github__create_pull_request`, `ade.github__add_issue_comment`. - `linear` -> `ade.linear__get_issue`, `ade.linear__save_comment`, `ade.linear__save_issue`. - `browser` -> `agent-browser`, `get_environment_info`, `launch_app`, `interact_gui`, `screenshot_environment`, `record_environment`, `ade.playwright__*`. -- `mission` -> `get_mission`, `get_run_graph`, `stream_events`, `get_timeline`, `get_pending_messages`, `report_status`, `report_result`, `ask_user`. `PUBLISH_CAPABLE_TOOL_FAMILIES` — `github`, `linear`, `browser` — are the families that can publish outputs externally. Guardrails apply specifically to these. @@ -132,9 +131,9 @@ Baseline tools (always available) come from `buildClaudeReadOnlyWorkerAllowedToo - Shared step controls: `condition` (gate string), `continueOnFailure`, `timeoutMs`, `retry`. - Per-action overrides (apply on top of `execution.*` defaults): - `targetLaneId` — overrides the lane this action runs against. Resolves through `getConfiguredTargetLaneId(rule, action)` → `execution.targetLaneId` → trigger lane → primary lane. - - `modelConfig` (`agent-session` only) — `{ modelId, thinkingLevel? }` overriding the rule's orchestrator model for this step. `thinkingLevel` also feeds the agent-session reasoning effort. + - `modelConfig` (`agent-session` only) — `{ modelId, thinkingLevel? }` overriding the rule's model for this step. `thinkingLevel` also feeds the agent-session reasoning effort. - `codexFastMode` (`agent-session` only) — boolean that enables Codex Fast Mode for this step. Only applied when the resolved provider group is `codex` AND the resolved model descriptor supports fast mode (`modelSupportsFastMode`); silently ignored otherwise. Forwarded to the chat service as `codexFastMode: true` on the session create, which causes Codex `thread/start` + `turn/start` JSON-RPC calls to carry `serviceTier: "fast"`. Mirrors the rule-level `execution.session.codexFastMode` toggle and stacks the same way as `modelConfig` / `permissionConfig`. - - `permissionConfig` (`agent-session` only) — `MissionPermissionConfig` whose `cli`/`providers`/`inProcess` fields are merged onto the rule's permission config; `providers.allowedTools` and `cli.allowedTools` extend (not replace) the rule's allow-list. The `cursor` provider key is supported alongside `claude`/`codex`/`opencode`. + - `permissionConfig` (`agent-session` only) — provider permission config whose `cli`/`providers`/`inProcess` fields are merged onto the rule's permission config; `providers.allowedTools` and `cli.allowedTools` extend (not replace) the rule's allow-list. The `cursor` provider key is supported alongside `claude`/`codex`/`opencode`. - Action-specific config on the same object (`command`, `cwd`, `suiteId`, `adeAction`, `prompt`, `sessionTitle`, `laneNameTemplate`, `laneDescriptionTemplate`, `parentLaneId`). Runtime `AutomationActionResult.status` is one of `running` | `succeeded` | `failed` | `skipped` | `cancelled`. Rows are persisted in the `automation_actions` table with `started_at`, `ended_at`, `output`, `error_message`. @@ -148,10 +147,9 @@ Action types (`AutomationActionType`): - `run-command` — shell command. `command` + optional `cwd`. Cwd validated via `validateAutomationCwd` + `resolvePathWithinRoot`; must stay inside the target lane worktree or project root. - `run-tests` — invokes the ADE test runner for `suiteId`. - `predict-conflicts` — runs the conflicts service's prediction for the target lane; no extra config. -- `launch-mission` — dispatches a mission via the mission runtime. Same surface as `execution.kind === "mission"` but from inside an action chain. - `agent-session` — embeds an agent-session step inside a built-in chain. `prompt` + optional `sessionTitle`; the rule's tool palette applies. - `ade-action` — dispatches a registered ADE action. `adeAction: RunAdeActionConfig`: - - `domain` — one of the allowlisted `AdeActionDomain` values (`lane`, `git`, `pr`, `issue`, `chat`, `mission`, `linear_sync`, `file`, `pty`, `automations`, etc.). + - `domain` — one of the allowlisted `AdeActionDomain` values (`lane`, `git`, `pr`, `issue`, `chat`, `linear_sync`, `file`, `pty`, `automations`, etc.). - `action` — an entry on that domain's allowlist (e.g. `pr.addComment`, `issue.close`, `linear_sync.runSyncNow`). - `args` — object or array passed to the domain method. Strings may contain `{{trigger.*}}` placeholders resolved from the trigger context at dispatch time. - `resolvers` — optional explicit `{ key: "trigger.path" }` mapping for placeholders that are not embedded in `args` strings. @@ -187,4 +185,3 @@ The planner output JSON is extracted with `extractFirstJsonObject` — it handle - `README.md` — rule structure, execution surfaces, budget. - `guardrails.md` — approval gates, confidence thresholds, human review. - `../cto/linear-integration.md` — Linear intake boundary. -- `../missions/README.md` — when a rule dispatches as `kind: "mission"`. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 18669e619..fc60b4cee 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -50,7 +50,7 @@ machinery layered on top. | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images use `ade.app.saveClipboardImageAttachment` before falling back to `ade.agentChat.saveTempAttachment`. | | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | -| `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO mission) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | +| `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | | `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts` | Chat chrome tokens. Exports `PROVIDER_CHAT_ACCENTS` (claude → amber, codex → warm white, cursor → violet, opencode → blue, etc.) and `providerChatAccent(provider)`. iOS mirrors this table in `ADEDesignSystem.swift`. | | `apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx` | Floating modal surface for question / structured-question pending inputs. Rendered above the transcript so the user can type or pick an option without losing the chat context. | | `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index e26a5a8a3..7046d93a2 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -49,8 +49,8 @@ stream plus session metadata. - Todo items via `deriveTodoItems()` in `chatExecutionSummary.ts`. - Subagent snapshots via `deriveChatSubagentSnapshots()`. - Turn diff summaries via `deriveTurnDiffSummaries()`. -3. Resolves a `ChatSurfacePresentation` (standard, resolver, mission - thread, mission feed) to drive header title, accent color, chips. +3. Resolves a `ChatSurfacePresentation` (standard, resolver, worker + thread, activity feed) to drive header title, accent color, chips. 4. Mounts the header, message list, composer, and the appropriate side panels based on the session's `executionMode` and capabilities. @@ -282,7 +282,7 @@ power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). | `useProviderAuthStatus.ts` | Resolves `AuthStatus` (`authenticated` / `missing` / `unknown`) per `ProviderFamily` from the AI integration status. | | `useAuthOnlyFilter.ts` | Hides models whose provider is not authenticated, with a toggle for the catalog browse mode. | | `useModelFavorites.ts` / `useModelRecents.ts` | Cross-surface favorites and recents persisted to `~/.ade/modelPicker.json` via the `modelPicker.*` JSON-RPC methods on `adeRpcServer`. The TUI shares the same store. | -| `usePerSurfaceModelDefaults.ts` | Per-surface default-model resolver (Settings, parallel slots, mission planning, etc.) — keyed by surface so each call site can have its own remembered default. | +| `usePerSurfaceModelDefaults.ts` | Per-surface default-model resolver (Settings, parallel slots, worker delegation, etc.) — keyed by surface so each call site can have its own remembered default. | | `useReasoningByFamily.ts` | Last-used reasoning effort per model family. | Renderer state and the TUI share descriptors and ordering: the TUI @@ -491,8 +491,8 @@ surface's visual treatment: | Field | Effect | |---|---| -| `mode` | `standard \| resolver \| mission-thread \| mission-feed`. | -| `profile` | `standard \| persistent_identity` -- persistent identity adjusts accent color, chips, title, and some layouts. | +| `mode` | `standard | resolver | worker-thread | activity-feed`. | +| `profile` | `standard | persistent_identity` -- persistent identity adjusts accent color, chips, title, and some layouts. | | `modelSwitchPolicy` | Overrides the default switch policy for the session. | | `title`, `subtitle`, `assistantLabel`, `messagePlaceholder` | Text overrides. | | `accentColor` | Accent color used in header, chips, and active-turn indicators. | diff --git a/docs/features/chat/tool-system.md b/docs/features/chat/tool-system.md index bc379a129..5b4ea63a1 100644 --- a/docs/features/chat/tool-system.md +++ b/docs/features/chat/tool-system.md @@ -2,7 +2,7 @@ Agents exposed through chat get three tiers of tools: **universal**, **workflow**, and **coordinator**. Each tier is scoped to a role so a -regular chat session cannot, for example, start a mission or force a +regular chat session cannot, for example, start a run or force a worker to respawn. ## Source file map @@ -66,7 +66,7 @@ through the same pending-input abstraction (see ## Tier 2: workflow tools Available to chat agents (CTO, named employees, regular chat sessions). -Not exposed to headless mission workers. Built by `createWorkflowTools()` +Not exposed to headless run workers. Built by `createWorkflowTools()` in `workflowTools.ts`. | Tool | Purpose | @@ -102,13 +102,6 @@ a platform without a supported capture backend (Linux/Windows); the agent can fall back to `ade proof attach ` with a headless-browser or Playwright-produced PNG in that case. -## Tier 3: coordinator tools - -Available only to the mission orchestrator agent. Not covered in this -doc; see the missions area. Chat agents do not receive these, and -`agentChatService` filters them out when provisioning a chat session -that happens to share a provider runtime with orchestrator code. - ## CTO operator tools Sessions with `identityKey: "cto"` additionally receive the CTO operator @@ -122,7 +115,6 @@ uses to act on ADE itself: | `createTerminal`, `runCommand` | Create untracked shells or run fire-and-forget commands. | | `listLanes`, `createLane`, `renameLane`, `archiveLane`, `inspectLane` | Lane management. | | `listWorkers`, `createAgent`, `updateAgent`, `triggerAgentWakeup` | Worker agent management. | -| `listMissions`, `startMission`, `pauseMission`, `resumeMission`, `cancelMission` | Mission control. | | Linear tools (when connected) | Intake, dispatch, reply, close. | | `getPipelineSettings`, `updatePipelineSettings` | Pipeline/flow policy. | | `getIssueInventory`, `refreshIssueInventory` | Issue tracking. | @@ -135,11 +127,10 @@ renders only the sections the agent can act on. ## Standalone-chat restrictions Chat sessions connected to the ADE CLI with a `chatSessionId` but -no mission/run/step/attempt context are classified as "standalone". The -ADE action bridge hides `spawn_agent` and all coordinator tools from both -the action-list response and the execution path. This prevents an interactive -chat user from invoking orchestration primitives that only function -inside a mission. +no worker context are classified as "standalone". The +ADE action bridge hides `spawn_agent` from both the action-list +response and the execution path. This prevents an interactive chat +user from invoking elevated primitives reserved for managed sessions. ## Tool exposure policy diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index b5b9fd0f0..bbd0720cc 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -31,9 +31,9 @@ Type definitions live in `apps/desktop/src/shared/types/chat.ts`. The envelope carries transport metadata; the actual payload is the discriminated `AgentChatEvent` union. -`provenance` is populated for mission-scoped chat, where messages can +`provenance` is populated for delegated worker chat, where messages can originate from orchestrator, worker, or user threads and must be routed -back to the correct mission feed. +back to the correct activity feed. ## Parsing @@ -51,7 +51,7 @@ Two helpers summarise a parsed stream: - `hasMaterialWorkerChatEvent(events)` -- returns true when any event type in `{ text, reasoning, tool_call, tool_result, command, - file_change }` is present. Used to gate mission-chat activity badges. + file_change }` is present. Used to gate worker-chat activity badges. - `hasWorkerChatLifecycleEvent(events)` -- returns true when any event other than `user_message` is present. - `deriveAgentChatTranscriptSummary(events, maxChars = 280)` -- returns @@ -82,12 +82,12 @@ Two helpers summarise a parsed stream: | `todo_update` | Task-list snapshot; consumed by `ChatTasksPanel`. | | `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId` + `agentType`: for Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). Claude SDK runs also stash `taskType` (`subagent` / `background` / `local_workflow` / `cron` / `other`) and `workflowName` at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's `skip_transcript=true` flag — e.g. session-title generation) are filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them. The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. | | `tool_use_start` / `tool_use_complete` / `tool_use_summary` | Claude SDK tool lifecycle tracking (see [Claude tool-use tracking](#claude-tool-use-tracking)). | -| `step_boundary` | Mission step boundary marker. | +| `step_boundary` | Workflow step boundary marker. | | `system_notice` | Non-transcript chrome: auth errors, rate limits, and file persistence hints. | | `session_meta_updated` | Runtime-native session metadata update (title / manual-name state). The renderer treats it as a local-touch event so Work lists and grid tiles refresh when a provider renames a session. | | `completion_report` | Structured closeout produced by the `reportCompletion` workflow tool. | | `turn_diff_summary` | Git-level before/after SHA + per-file stats for a completed turn. | -| `delegation_state` | Mission orchestrator delegation contract updates. | +| `delegation_state` | Delegated worker state updates. | | `context_compact` | Emitted before the provider compacts context (manual or auto). | | `web_search` | Web-search tool lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. | | `auto_approval_review` | When auto-approval policy kicks in, this event carries the review text. | diff --git a/docs/features/computer-use/README.md b/docs/features/computer-use/README.md index 4651f19f8..bad4a3933 100644 --- a/docs/features/computer-use/README.md +++ b/docs/features/computer-use/README.md @@ -1,6 +1,6 @@ # Computer Use -ADE does not run computer-use itself. Agents drive computer use through whatever tool they already have — Claude's `computer_use`, Codex shell, a scripted browser, a headless Playwright run. ADE's only job is to **ingest** the resulting artifact (screenshot, video, trace, verification output, console log), link it to an owner (chat, mission, lane, PR, Linear issue), and render it in the review drawer. +ADE does not run computer-use itself. Agents drive computer use through whatever tool they already have — Claude's `computer_use`, Codex shell, a scripted browser, a headless Playwright run. ADE's only job is to **ingest** the resulting artifact (screenshot, video, trace, verification output, console log), link it to an owner (chat, lane, PR, Linear issue), and render it in the review drawer. The previous control-plane model — policy modes (`off`/`auto`/`enabled`), readiness gates, per-phase evidence requirements, a passive proof observer — is gone. What remains is a thin broker backed by a single table. @@ -48,7 +48,6 @@ The `ade-cli` headless surface registers the same broker and exposes the equival ### Renderer - `apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx` — proof drawer mounted under the chat composer. Shows the `ComputerUseOwnerSnapshot` scoped to the active chat session. -- `apps/desktop/src/renderer/components/missions/MissionComputerUsePanel.tsx` and `MissionProofPanel.tsx` — mission-detail Proof tab. - `apps/desktop/src/renderer/lib/computerUse.ts`, `renderer/lib/proof.ts` — renderer helpers that call `window.ade.proof.*`. `ComputerUseSection.tsx` (Settings > Computer Use) was removed in this rebuild; its readiness display was folded into `IntegrationsSettingsSection`. @@ -63,9 +62,9 @@ The `ade-cli` headless surface registers the same broker and exposes the equival - `id`, `artifact_id`, `owner_kind`, `owner_id`, `relation`, `metadata_json`, `created_at`. -Owner kinds: `lane`, `mission`, `orchestrator_run`, `orchestrator_step`, `orchestrator_attempt`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. +Owner kinds: `lane`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. -One artifact can link to multiple owners — evidence flows from an exploratory chat to a mission artifact to a PR comment without losing provenance. +One artifact can link to multiple owners — evidence flows from an exploratory chat to a PR comment without losing provenance. ## Proof kinds @@ -105,7 +104,7 @@ Other paths are rejected. - `proofObserver.ts` and its test. - `ComputerUsePolicy` (`off`/`auto`/`enabled`, `allowLocalFallback`, `retainProof`, `preferredBackend`) — and the helpers `createDefaultComputerUsePolicy`, `normalizeComputerUsePolicy`, `isComputerUseModeEnabled`, `summarizePolicy`. -- Per-phase `evidenceRequirements` math and the mission preflight coverage/readiness gate. +- Per-phase `evidenceRequirements` math and preflight coverage/readiness gates. - Settings > Computer Use panel. - Ghost OS-specific readiness probes (`ghost status` / `ghost doctor` shelling and regex parsing). - Separate tool delivery for computer use. @@ -209,7 +208,6 @@ Current audit coverage for the tab lives in ## Cross-links - [`../proof.md`](../proof.md) — `ade proof` CLI and the drawer UI contract. -- [`../missions/README.md`](../missions/README.md) — mission detail renders the Proof tab from the same broker. - [`../cto/linear-integration.md`](../cto/linear-integration.md) — Linear closeout can attach broker-managed artifacts as proof. - [`../automations/README.md`](../automations/README.md) — automations that dispatch agent work rely on the agent's own `ade proof` calls; no automation-level proof policy exists. diff --git a/docs/features/computer-use/artifact-broker.md b/docs/features/computer-use/artifact-broker.md index af831c4a0..fbb5f52b7 100644 --- a/docs/features/computer-use/artifact-broker.md +++ b/docs/features/computer-use/artifact-broker.md @@ -1,6 +1,6 @@ # Computer-Use Artifact Broker -The broker is the normalization layer after external computer-use execution has happened. External tools perform the actual clicks, keystrokes, and captures. The broker ingests their output, stores it canonically, links it to owners (missions, chats, PRs, Linear issues), and tracks review and publication state. +The broker is the normalization layer after external computer-use execution has happened. External tools perform the actual clicks, keystrokes, and captures. The broker ingests their output, stores it canonically, links it to owners (runs, chats, PRs, Linear issues), and tracks review and publication state. The broker runs inside the runtime daemon (`ade serve`) that owns the project. Artifacts are written to that runtime host's `.ade/artifacts/computer-use/` directory; database rows live in that runtime's `.ade/ade.db`. Renderer reads/writes flow through `window.ade.proof.*` → preload → runtime JSON-RPC → broker; the desktop main process is no longer the owner of this state. @@ -40,13 +40,13 @@ Stored as `StoredLinkRow`: - `id` — UUID. - `artifact_id` — FK to the artifact. -- `owner_kind` — one of `lane`, `mission`, `orchestrator_run`, `orchestrator_step`, `orchestrator_attempt`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. +- `owner_kind` — one of `lane`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. - `owner_id` — the owner's id. - `relation` — default `attached_to`; can also be `produced_by`, `referenced_by`, `published_to`, etc. - `metadata_json` — per-link metadata. - `created_at`. -A single artifact can have multiple links — evidence that starts in a chat can attach to a mission, then to a PR, then to a Linear issue without being duplicated. +A single artifact can have multiple links — evidence that starts in a chat can attach to a PR, then to a Linear issue without being duplicated. ## Ingestion pipeline @@ -119,7 +119,7 @@ This is the symlink-safe copy path. Do not replace with plain `copyFileSync` — ``` { - kind: "lane" | "mission" | "orchestrator_run" | "orchestrator_step" | "orchestrator_attempt" | "chat_session" | "automation_run" | "github_pr" | "linear_issue", + kind: "lane" | "chat_session" | "automation_run" | "github_pr" | "linear_issue", id: string, relation?: "attached_to" | "produced_by" | "referenced_by" | "published_to" | ..., metadata?: Record @@ -129,10 +129,6 @@ This is the symlink-safe copy path. Do not replace with plain `copyFileSync` — Owner precedence for snapshots (`usageEventMatchesOwner`): - `chat_session` — matches usage events with `chatSessionId` or `callerId` matching the id. -- `mission` — matches events with `missionId`. -- `orchestrator_run` — matches events with `runId`. -- `orchestrator_step` — matches events with `stepId`. -- `orchestrator_attempt` — matches events with `attemptId`. ## Review state @@ -144,26 +140,21 @@ Owner precedence for snapshots (`usageEventMatchesOwner`): ## Routing and promotion -`routeArtifact(args)` adds a new link to a different owner — this is the "promote from chat to mission" path. The original link is preserved (to maintain provenance) and a new link is added with an appropriate `relation`. +`routeArtifact(args)` adds a new link to a different owner. The original link is preserved (to maintain provenance) and a new link is added with an appropriate `relation`. Example promotion flow: ``` chat_session:abc (relation: attached_to) - -> mission:xyz (relation: produced_by) -> github_pr:123 (relation: published_to) -> linear_issue:LIN-456 (relation: published_to) ``` All links remain in `computer_use_artifact_links`; the artifact record itself is unchanged. -## Projection into legacy artifact plumbing - -The broker projects broker-managed artifacts into older mission/orchestrator artifact plumbing where compatibility still matters (mission artifact lists, orchestrator artifact queries). `missionService` and `orchestratorService` read from the broker and expose broker artifacts under the normal artifact surface so UI that hasn't migrated yet still sees proof evidence. - ## Event emission -`onEvent(payload: ComputerUseEventPayload)` fires after every successful ingestion, review, or routing change. Renderer surfaces subscribe to this stream to refresh the proof drawer, mission run view, or Settings readiness snapshot without polling. +`onEvent(payload: ComputerUseEventPayload)` fires after every successful ingestion, review, or routing change. Renderer surfaces subscribe to this stream to refresh the proof drawer or Settings readiness snapshot without polling. ## Snapshots @@ -184,7 +175,6 @@ The broker projects broker-managed artifacts into older mission/orchestrator art Artifacts flow into downstream workflow surfaces: -- **Mission closeout** — broker artifacts attach to the result lane closeout record. - **Lane history** — linked lane surfaces the artifact in the lane timeline. - **Chat history** — linked chat session surfaces the artifact in the thread. - **GitHub PR workflows** — linked PR gets a comment with the artifact reference (when published). @@ -214,5 +204,4 @@ Publication paths call `routeArtifact` or `reviewArtifact` depending on the tran - `README.md` — control-plane role, proof kinds, backend overview. - `backends.md` — Ghost OS, agent-browser, ADE local detection and capabilities. - `settings-and-readiness.md` — Settings > Computer Use surface. -- `../missions/README.md` — mission preflight and run monitoring consume broker snapshots. - `../cto/linear-integration.md` — Linear closeout attaches broker artifacts via routing. diff --git a/docs/features/computer-use/settings-and-readiness.md b/docs/features/computer-use/settings-and-readiness.md index e6688d8b7..9a2f46dbd 100644 --- a/docs/features/computer-use/settings-and-readiness.md +++ b/docs/features/computer-use/settings-and-readiness.md @@ -78,7 +78,7 @@ Each row lists which registered external backends declared support (via `support `ComputerUsePolicy` is edited in two places: 1. **Global default** — Settings > Computer Use. Controls the project-wide default policy. -2. **Per-scope override** — mission settings (for mission scope), chat header (for chat session scope), lane metadata (for lane scope). +2. **Per-scope override** — chat header (for chat session scope), lane metadata (for lane scope). Fields: @@ -118,17 +118,6 @@ In a chat session, the header shows the policy toggle and summary: The inline monitor renders `buildComputerUseOwnerSnapshot({ broker, owner: { kind: "chat_session", id }, policy })` for live backend / activity / artifact status. -## Mission preflight readiness - -Mission preflight computes: - -- Required proof kinds from `collectRequiredComputerUseKindsFromPhases(phases)` (reads `phase.validationGate.evidenceRequirements` for kinds in `COMPUTER_USE_KINDS`). -- Current `ComputerUsePolicy` (mission metadata > project default). -- Available external backends. -- Gap analysis: which required kinds have no available backend? - -Gaps become preflight warnings. If the gap is not satisfiable externally and local fallback is disallowed for the scope, launch is blocked. - ## Setup flows ### Ghost OS (from Settings > Computer Use) @@ -178,5 +167,4 @@ From Settings: - `README.md` — control-plane overview. - `backends.md` — detection internals for Ghost OS, agent-browser, ADE local. - `artifact-broker.md` — ingestion, storage, review, publication. -- `../missions/README.md` — preflight readiness checks consume this surface. - `../cto/README.md` — CTO operator tool surface includes computer-use artifact actions. diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index 946a3bf7e..4dc7b9793 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -26,7 +26,7 @@ The runtime is organized around one contract: the CTO tab should be usable as a - `linearRoutingService.ts` — match a normalized issue against the workflow list and produce a `LinearWorkflowMatchResult`. - `linearIngressService.ts` — optional realtime webhook/relay ingress; auto-starts only if configured. - `linearSyncService.ts` — background polling loop; short-circuits when idle/disconnected. -- `linearDispatcherService.ts` — launches target runs (employee_session, worker_run, mission, pr_resolution, review_gate), tracks run state, emits events. +- `linearDispatcherService.ts` — launches target runs (employee_session, worker_run, run, pr_resolution, review_gate), tracks run state, emits events. - `linearCloseoutService.ts` — success/failure Linear state transitions, comments, proof attachment. - `linearOutboundService.ts` — outbound Linear writes (state, comments, assignees). @@ -76,7 +76,7 @@ The runtime is organized around one contract: the CTO tab should be usable as a 1. **Immutable doctrine** — `IMMUTABLE_CTO_DOCTRINE` in `ctoStateService.ts`. Defines the CTO role, ADE environment, precision rules. Always injected. Not user-editable. Not compacted away. Runs even after context compaction via `refreshReconstructionContext()`. 2. **Personality overlay** — one of six presets (`strategic`, `professional`, `hands_on`, `casual`, `minimal`, `custom`). Only the `custom` preset reads `customPersonality` from the identity record. 3. **CTO continuity model** — the runtime describes doctrine, current context, and compaction/recovery rules. -4. **Environment knowledge** — `CTO_ENVIRONMENT_KNOWLEDGE` is a glossary of ADE entities (lanes, chats vs terminals vs subprocess agents, missions, workers, convergence, conflicts) plus the intent-to-tool routing guide. Distinguishes `spawnChat` from `createTerminal` from `spawn_agent` explicitly. +4. **Environment knowledge** — `CTO_ENVIRONMENT_KNOWLEDGE` is a glossary of ADE entities (lanes, chats vs terminals vs subprocess agents, runs, workers, convergence, conflicts) plus the intent-to-tool routing guide. Distinguishes `spawnChat` from `createTerminal` from `spawn_agent` explicitly. 5. **Capability manifest** — `CTO_CAPABILITY_MANIFEST` lists the complete operator tool surface. It is intentionally kept in sync with `ctoOperatorTools.ts` tool registrations, not auto-generated. These layers combine into `CtoSystemPromptPreview` which the onboarding and settings surfaces render verbatim, so the UI matches the runtime. @@ -117,7 +117,7 @@ Linear poll / webhook -> linearSyncService (reconciliation loop) -> linearRoutingService (match triggers against LinearWorkflowConfig) -> linearDispatcherService (launch target; emit linear-workflow-run events) - -> workerAgentService / missionService / agentChatService / prService (target-specific launch) + -> workerAgentService / agentChatService / prService (target-specific launch) -> linearCloseoutService (on completion) -> renderer via emitRunEvent + ipc channel -> LinearSyncPanel dashboard / run timeline @@ -125,7 +125,7 @@ Linear poll / webhook ## CTO operator tools -Registered in `ctoOperatorTools.ts` and exposed as ADE CLI actions to the CTO chat session. Organized by domain: lanes, chats, missions, workers, git, PRs, convergence, conflicts, files, context, processes, tests, terminals, Linear, automations, events, project health, computer use, budget, and CTO continuity. When the CTO wants to surface something in the UI it returns an `OperatorNavigationSuggestion` instead of silently switching tabs. +Registered in `ctoOperatorTools.ts` and exposed as ADE CLI actions to the CTO chat session. Organized by domain: lanes, chats, workers, git, PRs, convergence, conflicts, files, context, processes, tests, terminals, Linear, automations, events, project health, computer use, budget, and CTO continuity. When the CTO wants to surface something in the UI it returns an `OperatorNavigationSuggestion` instead of silently switching tabs. The environment knowledge block inside the system prompt teaches intent-to-tool routing (e.g. "start a chat" -> `spawnChat`, "open a terminal" -> `createTerminal`). The capability manifest is injected in full, not summarized, so the CTO can pick the right tool even for less common actions. @@ -136,7 +136,6 @@ The environment knowledge block inside the system prompt teaches intent-to-tool - `linear-integration.md` — connection model, workflow engine, dispatcher, sync loop, ingress, headless parity. - `workers.md` — worker creation wizard, team panel, adapter types, budgets. - `onboarding.md` — `OnboardingBanner`, `OnboardingWizard`, identity editor. -- `../missions/README.md` — missions as a dispatch target. - `../automations/README.md` — automations as event-driven rules; note CTO owns Linear intake, Automations never duplicate it. - `../computer-use/README.md` — computer-use proof appears in workflow closeout. diff --git a/docs/features/cto/linear-integration.md b/docs/features/cto/linear-integration.md index 9cfe8974a..732786234 100644 --- a/docs/features/cto/linear-integration.md +++ b/docs/features/cto/linear-integration.md @@ -47,7 +47,7 @@ Detailed wiring lives in [`../linear-integration/README.md`](../linear-integrati ### Runtime daemon -- `apps/ade-cli/src/headlessLinearServices.ts` — instantiates the full Linear service stack inside the `ade serve` runtime daemon. The daemon is first-class for Linear, not a read-only stub: it can intake issues, dispatch worker runs / missions / employee sessions, and close out tickets with the same code path the desktop renderer drives. +- `apps/ade-cli/src/headlessLinearServices.ts` — instantiates the full Linear service stack inside the `ade serve` runtime daemon. The daemon is first-class for Linear, not a read-only stub: it can intake issues, dispatch worker runs / runs / employee sessions, and close out tickets with the same code path the desktop renderer drives. ## Connection model @@ -80,8 +80,7 @@ The dispatcher handles five target types: | --- | --- | --- | | `employee_session` | Direct CTO or employee chat with issue context | Uses `agentChatService.createSession`. Honors `sessionReuse` (fresh vs continue). | | `worker_run` | Delegated isolated worker run | Uses `workerAgentService` + `workerTaskSessionService`. Fresh lane by default. | -| `mission` | Full mission via `aiOrchestratorService` | Uses `missionService.createMission` + mission run start. | -| `pr_resolution` | PR-focused automation | Can spin up a worker or mission depending on config. Applies PR convergence policy. | +| `pr_resolution` | PR-focused automation | Can spin up a worker depending on config. Applies PR convergence policy. | | `review_gate` | Manual gate | No work launched; surfaces review request and waits on human decision. | The dispatcher supports chained stages via `downstreamTarget` — e.g. a worker run feeding into a PR resolution. `getTargetStages(target)` walks the chain (mirror of `flattenTargetChain` in the pipeline builder). @@ -92,7 +91,7 @@ The dispatcher supports chained stages via `downstreamTarget` — e.g. a worker - workflow id/name/version/source, target type, status. - current step index + id. -- `execution_lane_id`, `linked_mission_id`, `linked_session_id`, `linked_worker_run_id`, `linked_pr_id`. +- `execution_lane_id`, `linked_session_id`, `linked_worker_run_id`, `linked_pr_id`. - review state, supervisor identity key, review-ready reason. - PR state, checks status, review status. - retry count + next-retry timestamp. @@ -204,5 +203,4 @@ Workflows support a simulation mode for testing triggers against real issues wit - `pipeline-builder.md` — editing workflows visually. - `workers.md` — how `worker_run` targets actually execute. - `../automations/README.md` — why Automations don't duplicate Linear intake. -- `../missions/README.md` — how `mission` targets dispatch to the mission runtime. - `../computer-use/README.md` — proof artifacts attached during closeout. diff --git a/docs/features/cto/onboarding.md b/docs/features/cto/onboarding.md index be5c1805a..71a614ad2 100644 --- a/docs/features/cto/onboarding.md +++ b/docs/features/cto/onboarding.md @@ -73,7 +73,7 @@ The editor reuses the personality preset grid from the wizard. Nothing outside t `CtoPromptPreview.tsx` renders the system prompt exactly as the runtime assembles it: -1. Doctrine — immutable ADE-owned CTO mission statement. +1. Doctrine — immutable ADE-owned CTO charter. 2. Personality overlay — the selected preset's `systemOverlay` text, or the custom text. 3. CTO continuity + environment model — the operating rules for continuity and the intent-to-tool routing map. diff --git a/docs/features/cto/pipeline-builder.md b/docs/features/cto/pipeline-builder.md index 4ce5e6182..fd65978c2 100644 --- a/docs/features/cto/pipeline-builder.md +++ b/docs/features/cto/pipeline-builder.md @@ -42,13 +42,12 @@ Translation happens in `pipelineHelpers.ts`: Insertion at index 0 replaces the primary target and pushes everything down — this is intentional because the "primary" target in the backend is always the first stage. `removeStageAt` refuses to remove the only remaining stage. -`createDefaultStage(type)` provides sensible defaults for each of the five target types: +`createDefaultStage(type)` provides sensible defaults for each target type: | Target type | Default runMode | Default lane | Default sessionReuse / PR | | --- | --- | --- | --- | | `employee_session` | `assisted` | `fresh_issue_lane` | `fresh_session`, `prTiming: "none"` | | `worker_run` | `autopilot` | `fresh_issue_lane` | `prTiming: "none"` | -| `mission` | `autopilot` | n/a | `missionTemplate: "default"` | | `pr_resolution` | `autopilot` | `fresh_issue_lane` | `prStrategy: per-lane+draft`, `prTiming: "after_target_complete"` | | `review_gate` | `manual` | n/a | none | @@ -114,7 +113,7 @@ Every field in `FIELD_LABELS` carries a `tier` (`essential`, `advanced`, `expert - **Don't edit `workflow.steps` directly** from the UI. Use `rebuildWorkflowSteps(workflow, patch)` so managed steps stay in sync and non-managed steps are preserved. Manual step edits are the most common way to drop the wait/review/notify sequencing. - **Don't mutate `target.downstreamTarget` in place.** Use `insertStageAt` / `removeStageAt` / `updateStageAt` which rebuild the chain. - **Stage 0 is primary.** Inserting at index 0 reassigns the primary target. Some call sites assume that and pass `afterIndex: -1` semantics via `handleAddStage`. If you change `insertStageAt` to accept negative indices, audit callers. -- **Preset ids are stable.** Don't rename them (`assigned-employee-session`, `assigned-mission-run`, etc.) — downstream repo YAML pins to them. +- **Preset ids are stable.** Don't rename them (`assigned-employee-session`, etc.) — downstream repo YAML pins to them. - **`STAGE_COLORS` keys must cover every target type.** Missing entries silently fall back to purple; add new types to both `STAGE_COLORS` and `TYPE_ICONS` in `StageCard.tsx`. - **`visualManagedStepTypes` is the contract boundary.** Adding a new step type that should be rebuilt from the visual plan requires adding it to the set in `linearWorkflowPresets.ts` or it will be preserved-but-not-regenerated (often leading to stale steps after a plan change). - **Tests in `pipelineHelpers.test.ts` and `linearWorkflowPresets.test.ts`** cover the translation invariants. Keep them green — they are the regression net for this surface. @@ -126,4 +125,3 @@ Every field in `FIELD_LABELS` carries a `tier` (`essential`, `advanced`, `expert - `README.md` — overall CTO architecture and the source file map that includes this surface. - `linear-integration.md` — how these workflow definitions drive dispatcher, routing, and sync. - `../agents/identity-and-personas.md` — `supervisorIdentityKey` ties into the same identity model used by CTO and worker chat sessions. -- `../missions/README.md` — `target.type === "mission"` dispatches through the mission runtime. diff --git a/docs/features/cto/workers.md b/docs/features/cto/workers.md index b89bc03df..9d06e169f 100644 --- a/docs/features/cto/workers.md +++ b/docs/features/cto/workers.md @@ -1,6 +1,6 @@ # Workers (Team) -Workers are named agent identities that ADE can wake for delegated work. The CTO owns the team; workers execute inside lanes with their own budgets and heartbeats. Workers are distinct from mission-run workers (which are transient and role-based) — a Team worker is a stable, configurable identity. +Workers are named agent identities that ADE can wake for delegated work. The CTO owns the team; workers execute inside lanes with their own budgets and heartbeats. ## Source file map @@ -10,7 +10,7 @@ Workers are named agent identities that ADE can wake for delegated work. The CTO - `workerBudgetService.ts` — budget caps and spend tracking per worker and for the CTO org overall. `recordCostEvent`, budget snapshots, monthly rollups. - `workerHeartbeatService.ts` — heartbeat policy (interval, pause threshold), liveness reporting, activity feed updates. - `workerRevisionService.ts` — config revision history; every identity change lands as a new `AgentConfigRevision`. -- `workerTaskSessionService.ts` — short-lived task session records that tie a worker to a lane/issue/run. +- `workerTaskSessionService.ts` — short-lived task session records that tie a worker to a lane or issue. - `workerAdapterRuntimeService.ts` — adapter lifecycle management for the three worker adapter types: `claude-local`, `codex-local`, and `process`. ### Renderer (apps/desktop/src/renderer/components/cto/) @@ -116,7 +116,7 @@ Worker sessions undergo the same post-compaction identity re-injection as CTO se ## Runs -`WorkerAgentRun` records a delegated run with `laneId`, `issueKey`, `sessionId`, `status`, timestamps, and cost totals. `workerTaskSessionService` ties runs to their triggering lane/issue/mission. Runs surface in: +`WorkerAgentRun` records a delegated run with `laneId`, `issueKey`, `sessionId`, `status`, timestamps, and cost totals. `workerTaskSessionService` ties runs to their triggering lane or issue. Runs surface in: - the Team panel's Recent Runs list, - the sidebar budget footer (monthly totals), @@ -146,4 +146,3 @@ This prevents accidental commit of API keys into worker config revisions. - `../agents/identity-and-personas.md` — worker identity, reconstruction, and persona rules. - `linear-integration.md` — worker runs launched via `worker_run` target type. - `onboarding.md` — first-run flow that gates before the team is set up. -- `../missions/workers.md` — mission-run workers (transient role-based runtime) are a different concept. diff --git a/docs/features/history/README.md b/docs/features/history/README.md index 6eaf7b18f..a48419ae3 100644 --- a/docs/features/history/README.md +++ b/docs/features/history/README.md @@ -3,14 +3,14 @@ History is ADE's per-project timeline. It has two surfaces sharing a single page: a **Commits** view (GitKraken-style DAG for the focused lane) and an **Activity** view (the unified operations + sessions + -missions + worker feed). Both surfaces live inside `HistoryPage` and +worker feed). Both surfaces live inside `HistoryPage` and sync through a shared zustand store; the URL remembers which surface, which lane, and which selection are active so deep links land back on the same row. The Activity feed is sourced from the `operations` SQLite table plus in-memory adapters that synthesize timeline rows from chat sessions, -missions, CTO sessions, and worker runs. Git commits in the Commits +CTO sessions, and worker runs. Git commits in the Commits view come from `git log` on the lane's worktree, not from the operations table. @@ -35,7 +35,7 @@ the user's local machine — the runtime returns the rows, then the desktop's IPC handler writes the CSV/JSON to disk through the native save dialog. -Supplemental activity sources (chat sessions, missions, CTO snapshot, +Supplemental activity sources (chat sessions, CTO snapshot, worker runs) are fetched directly from the renderer through the existing per-feature preload bridges and merged into the timeline at render time; they are not persisted into `operations`. @@ -47,7 +47,7 @@ Main process / runtime services: | Path | Role | |---|---| | `apps/desktop/src/main/services/history/operationService.ts` | CRUD for `operations` rows; the canonical entry point for `record`, `start`, `finish`, `list`, `get`, and `listHeadChanges`. Same source backs the runtime daemon and the desktop fallback path. | -| `apps/desktop/src/main/services/state/kvDb.ts` | Schema for `operations`, `checkpoints`, `pack_events`, `pack_versions`, `pack_heads`, `terminal_sessions`, `orchestrator_chat_threads`, `orchestrator_chat_messages`. | +| `apps/desktop/src/main/services/state/kvDb.ts` | Schema for `operations`, `checkpoints`, `pack_events`, `pack_versions`, `pack_heads`, `terminal_sessions`. | | `apps/desktop/src/main/services/git/gitOperationsService.ts` | Brackets every git operation with `operationService.start` / `finish`, captures pre/post HEAD SHAs, and owns the per-lane undo/redo head-change pipeline (`undoLastHeadChange`, `redoLastHeadChange`, `createTag`, `resetToCommit`, `pull` with `ff-only` / `rebase` / `merge` modes). | | `apps/desktop/src/main/services/lanes/laneService.ts` | Lane CRUD now accepts `CreateLaneArgs.startPoint`, used by the Commits view's "Create lane here" affordance to fork a new lane from a specific commit. | | `apps/desktop/src/main/services/prs/prService.ts` | Records PR creation as an operation. | @@ -63,7 +63,7 @@ Renderer components (`apps/desktop/src/renderer/components/history/`): | `useTimelineStore.ts` | Zustand store: raw + enriched events, WIP-by-lane nodes, surface, focus lane, selected commit/event, view mode, scope level, filters, lane visibility, columns. `fetchEvents` merges `history.listOperations` with `fetchSupplementalTimelineRecords` and sorts/dedupes via `sortTimelineRecords`. | | `timelineTypes.ts` | `HistorySurface = "activity" \| "commits"`, `ViewMode = "graph" \| "list" \| "compact"`, `TimelineEvent` (enriched `OperationRecord`), `LaneTrack`, `GraphLayout`, `TimelineFilters`, `LaneVisibility`, `ColumnConfig`, `WIPNode`, `MinimapBucket`. `DEFAULT_COLUMNS` order is timestamp → graph → event → lane → author → status → duration → sha. | | `eventTaxonomy.ts` | Source of truth for event categories, importance levels, node shapes, and the per-kind `EVENT_KIND_META` table that every renderer (graph, list, compact, detail panel) consults. Adds taxonomy entries for the new git head-change kinds (`git_undo_head_change`, `git_redo_head_change`, `git_tag_create`, `git_reset_soft`/`_mixed`/`_hard`) and the unified-feed kinds (`chat.session`, `cto.session`, `worker.run`, `worker.activity`). | -| `historyActivitySources.ts` | Pure mappers + the single `fetchSupplementalTimelineRecords(limit)` entry point that builds synthetic `OperationRecord` rows from `agentChat.list`, `missions.list`, `cto.getState`, and `cto.listAgentRuns`. Synthetic IDs are namespaced (`chat:`, `mission:`, `worker-run:`, `cto-session:`, `cto-activity:`) and the actor/eventLabel are embedded in `metadataJson` so the detail panel + graph can render them uniformly. | +| `historyActivitySources.ts` | Pure mappers + the single `fetchSupplementalTimelineRecords(limit)` entry point that builds synthetic `OperationRecord` rows from `agentChat.list`, `cto.getState`, and `cto.listAgentRuns`. Synthetic IDs are namespaced (`chat:`, `worker-run:`, `cto-session:`, `cto-activity:`) and the actor/eventLabel are embedded in `metadataJson` so the detail panel + graph can render them uniformly. | | `historySearch.ts` | Tokenizer + matcher behind the Commits view search input. Supports bare full-text, quoted phrases, and the `message:` / `msg:` / `=` / `author:` / `@` / `commit:` / `sha:` / `#` / `branch:` / `ref:` / `parent:` / `is:` / `type:` keys (e.g. `is:merge`, `is:local`, `type:pushed`). | | `commitGraphLayout.ts` | Pure `buildCommitGraphLayout(commitsNewestFirst)` that assigns DAG columns first-parent style, then exposes `commitEdgePath`, `columnCenterX`, `rowCenterY`, and the `COMMIT_ROW_HEIGHT` / `COMMIT_GRAPH_COL_WIDTH` / `COMMIT_GRAPH_PAD_LEFT` constants the SVG layer in `CommitHistoryView` consumes. | | `CommitHistoryView.tsx` | Virtualized GitKraken-style commit graph for the focused lane. Loads commits via `git.listRecentCommits` (initial limit 120, expands to 500 on scroll-to-bottom and on non-empty search), `git.listBranches` for ref pills, draws nodes + edges as an SVG layer overlaid on `@tanstack/react-virtual` rows, and dispatches right-click commit actions via `HistoryGitContextMenu`. | @@ -75,7 +75,7 @@ Renderer components (`apps/desktop/src/renderer/components/history/`): | `TimelineGraph.tsx` (lazy) | SVG-based per-lane swimlane graph for the Activity surface — used when `viewMode === "graph"`. | | `TimelineListView.tsx` | Table-style activity list with column visibility driven by `columns` from the store. | | `TimelineCompactView.tsx` | Density-optimized one-line-per-event list. | -| `EventDetailPanel.tsx` | Right pane for the Activity surface: label, status, lane chip, pre/post SHA buttons, parsed metadata, jump-to-lane / open-mission / open-chat links derived from `metadata.missionId` / `metadata.sessionId`. | +| `EventDetailPanel.tsx` | Right pane for the Activity surface: label, status, lane chip, pre/post SHA buttons, parsed metadata, jump-to-lane / open-chat links derived from metadata. | Shared types: @@ -119,7 +119,7 @@ without leaving the History tab. ### Activity Unified operations feed combining recorded `OperationRecord` rows with -synthesized rows from chat sessions, missions, CTO snapshots, and +synthesized rows from chat sessions, CTO snapshots, and worker runs (see `historyActivitySources.ts`). `fetchEvents` merges them in `useTimelineStore`, deduplicates by `id`, and sorts by `startedAt` descending before clamping to `limit`. @@ -168,7 +168,7 @@ runs the save dialog locally and writes the file. The button shows ## What history captures History records **operations** — discrete, typed actions that changed -state — plus synthesized rows from the chat/mission/CTO/worker feeds +state — plus synthesized rows from the chat/CTO/worker feeds that pretend to be operations for rendering purposes only. Synthesized rows are not written into the `operations` table; they are rebuilt on every refresh. @@ -209,7 +209,6 @@ the activity feed and the detail panel; they will never appear in | Kind | Source | Metadata highlights | |---|---|---| | `chat.session` | `agentChat.list({ includeAutomation: true })` | `sessionId`, `title`, `provider`, `model`, `chatStatus`, `awaitingInput`, `automationId` | -| `mission.completed` / `failed` / `intervention` / `update` | `missions.list({ limit, includeArchived: true })` | `missionId`, `title`, `priority`, `missionStatus`, `completedSteps`, `totalSteps`, `openInterventions`, `artifactCount`, `lastError` | | `worker.run` | `cto.listAgentRuns({ limit })` | `runId`, `agentId`, `taskKey`, `workerStatus`, `wakeupReason`, `error` | | `cto.session` | `cto.getState({ recentLimit }).recentSessions` | `sessionId`, `summary`, `provider`, `model`, `capabilityMode` | | `worker.activity` | `cto.getState({ recentLimit }).recentSubordinateActivity` | `agentId`, `agent`, `sessionId`, `taskKey`, `activityType` | @@ -284,13 +283,6 @@ rows into the activity feed yet — chat sessions do. - Track pack content hashes and which version is "live" per pack key. - Used by deterministic-context exports. -### Orchestrator chat threads and messages - -`orchestrator_chat_threads`, `orchestrator_chat_messages`: - -- Mission-scoped chat threads with structured message records. -- Used by the mission feed UI. - ### AI usage log `ai_usage_log`: @@ -424,7 +416,7 @@ these are surfaced on the Activity surface as synthesized rows: - **CSV export escaping.** The export path embeds metadata JSON; CSV escaping must survive nested quotes. Validate with round-trip tests when adjusting export formats. -- **Synthesized rows do not export.** Chat / mission / CTO / worker +- **Synthesized rows do not export.** Chat / CTO / worker rows are renderer-side only. They appear in the Activity feed and the detail panel but never in `ade.history.exportOperations` output. Treat the export as the operations table, not the unified diff --git a/docs/features/history/recording-and-export.md b/docs/features/history/recording-and-export.md index 05286ff36..71f6c48a0 100644 --- a/docs/features/history/recording-and-export.md +++ b/docs/features/history/recording-and-export.md @@ -26,7 +26,7 @@ user's local machine). | `apps/desktop/src/main/services/sessions/sessionService.ts` | Terminal session lifecycle (writes `terminal_sessions` rows and persists transcripts to disk). | | `apps/desktop/src/shared/chatTranscript.ts` | JSON-lines parser for chat transcripts; used to reconstruct chat state, generate summaries, and derive activity signals. | | `apps/desktop/src/main/services/ipc/registerIpc.ts` | `ade.history.listOperations` and `ade.history.exportOperations` handlers, plus the new git IPC the History toolbar relies on (`gitCreateTag`, `gitResetToCommit`, `gitUndoLastHeadChange`, `gitRedoLastHeadChange`, multi-mode `gitPull`). | -| `apps/desktop/src/renderer/components/history/historyActivitySources.ts` | Renderer-side adapters that synthesize Activity-feed rows from `agentChat.list`, `missions.list`, `cto.getState`, and `cto.listAgentRuns`. These rows are not written to the operations table. | +| `apps/desktop/src/renderer/components/history/historyActivitySources.ts` | Renderer-side adapters that synthesize Activity-feed rows from `agentChat.list`, `cto.getState`, and `cto.listAgentRuns`. These rows are not written to the operations table. | ## Recording pattern @@ -201,22 +201,22 @@ guard trips. The UI surfaces those errors verbatim through The Activity surface in the History UI merges persisted `OperationRecord` rows with synthesized rows pulled from chat, -mission, CTO, and worker feeds. Synthesis happens in +CTO, and worker feeds. Synthesis happens in `apps/desktop/src/renderer/components/history/historyActivitySources.ts` and never writes to the operations table. The renderer fetches the four feeds in parallel with `fetchSupplementalTimelineRecords(limit)` and folds the results into `OperationRecord`-shaped objects with namespaced IDs (`chat:`, -`mission:`, `worker-run:`, `cto-session:`, `cto-activity:`). Each +`worker-run:`, `cto-session:`, `cto-activity:`). Each record carries: -- `kind` -- `chat.session`, `mission.{completed|failed|intervention|update}`, - `worker.run`, `cto.session`, or `worker.activity`. The taxonomy in +- `kind` -- `chat.session`, `worker.run`, `cto.session`, or + `worker.activity`. The taxonomy in `eventTaxonomy.ts` ensures each kind has a category, icon, and importance level. -- `status` -- mapped from the source's status enum (e.g. mission - `intervention_required` → `running`, worker `cancelled` → `canceled`). +- `status` -- mapped from the source's status enum (for example, + worker `cancelled` → `canceled`). - `metadataJson` -- a `JSON.stringify`'d object with at minimum `source`, `eventLabel`, and an `actor` field so the detail panel can render uniformly regardless of source. @@ -244,8 +244,6 @@ parser is `parseAgentChatTranscript` in - `event` -- the `AgentChatEvent` discriminated union. - `sequence` -- optional monotonic index for ordering across parallel streams. -- `provenance` -- optional metadata for mission-scoped chats (thread - id, role, source session id, attempt id, step key, lane id, run id). Malformed lines are silently skipped; the parser is tolerant by design so a single corrupt line does not poison an entire transcript. @@ -355,4 +353,4 @@ so CSV consumers must respect RFC 4180-style quoting. - [Agents README](../agents/README.md) -- CTO and worker session logs (tracked separately in `cto_session_logs`). - \ No newline at end of file + diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index bc3a1c587..df6af644b 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -59,7 +59,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, mission role tagging, startup repair routines, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | | `laneEnvironmentService.ts` | Environment init pipeline: env files, docker services, dependencies, mount points, copy paths (Phase 5 W1) | @@ -210,7 +210,6 @@ a lane parented to primary would always show zero behind. - `stackDepth: number` - `childCount: number` - `tags: string[]`, `color`, `icon`, `folder` -- `missionId`, `laneRole` (nullable; see mission roles) - `devicesOpen?: LaneDevicePresence[]` — decoration added by `syncHostService` on response paths (`lanes.list`, `lanes.getDetail`, `lanes.create`, `lanes.attach`, etc.) from the in-memory lane @@ -251,23 +250,6 @@ a lane parented to primary would always show zero behind. See [features/linear-integration/README.md](../linear-integration/README.md) for the cross-feature picture. -## Mission lane roles - -Lanes may belong to a mission via `missionId` + `laneRole`. Roles: - -| Role | Meaning | -|------|---------| -| `mission_root` | Base lane the mission launched from | -| `worker` | Lane for an individual worker agent | -| `integration` | Merge target (legacy, retained for compatibility) | -| `result` | Single output lane holding consolidated changes | - -`laneService.setMissionOwnership()` tags or re-tags a lane after -creation. `createChildLane` also accepts these fields so worker/result -lanes are tagged at birth. Mission-owned worker lanes are hidden by -default from the Lanes list (see `isMissionLaneHiddenByDefault` in -`renderer/components/lanes/laneUtils.ts`). - ## Lane lifecycle 1. **Create** — `laneService.create()` resolves the base ref (explicit @@ -532,6 +514,3 @@ open lanes; primary lanes render with a home icon. - **Worktree paths must remain absolute.** `laneService` stores resolved absolute paths. Relative paths persisted by a bad caller break `git -C` across shells. -- **Mission lanes hidden by default.** If a test expects a mission - worker lane to be visible, it must explicitly include mission - lanes via `isMissionLaneHiddenByDefault` filter bypass. diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index de8a0f853..47a237583 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -2,8 +2,8 @@ ADE's Linear integration attaches a Linear workspace to the CTO autonomous orchestration layer. It ingests issues from Linear, matches each issue against -user-defined workflow definitions, dispatches the matched work as an ADE -mission, employee chat session, worker run, or PR resolution, and closes the +user-defined workflow definitions, dispatches the matched work as an +employee chat session, worker run, or PR resolution, and closes the issue back out (state transition, comment, artifact links) when the run terminates. @@ -41,12 +41,7 @@ The integration is used by four distinct consumers: default supervisor for review gates (`reviewerIdentityKey: "cto"`). Linear runs show up in the CTO's history and feed the `awaiting_human_review` and `awaiting_delegation` queues that operators resolve from the CTO tab. -2. **Missions.** When a Linear workflow's target type is `mission`, the - dispatcher launches a mission through `missionService` / - `aiOrchestratorService`, links the mission back to the - `LinearWorkflowRun` row, and waits for mission completion before moving - on to PR gates or closeout. -3. **Lanes, commits, PRs, and chat.** A user can attach a Linear issue +2. **Lanes, commits, PRs, and chat.** A user can attach a Linear issue to a brand-new lane from `CreateLaneDialog` (the Linear issue picker in the always-open Advanced section), or to chat context from the composer's Linear attach affordance. Once a lane is connected to an @@ -58,13 +53,13 @@ The integration is used by four distinct consumers: the chat composer uses, lets the operator filter / search across their Linear backlog, and turns any selected issue into a new lane in one click. -4. **The headless ADE CLI.** `apps/ade-cli/src/headlessLinearServices.ts` +3. **The headless ADE CLI.** `apps/ade-cli/src/headlessLinearServices.ts` instantiates the full Linear service stack (sync, dispatcher, closeout, intake, ingress, routing, outbound, templates) so external callers can trigger and resolve Linear runs without the desktop UI running. The ADE CLI exposes these over JSON-RPC tools such as `listLinearWorkflows`, `resolveLinearRunAction`, `routeLinearIssueToCto`, - `routeLinearIssueToMission`, and `resolveLinearSyncQueueItem`. + `routeLinearIssueToWorker`, and `resolveLinearSyncQueueItem`. ## Top-level shape @@ -93,8 +88,7 @@ Linear (webhook / polled issues) | | | v | | +-----------------------+ launches | | -| missionService / |----------+ | -| agentChatService / | | +| agentChatService / |----------+ | | workerAgentService / | | | prService | | +-----------------------+ | @@ -131,7 +125,7 @@ A `LinearWorkflowDefinition` has six main parts: Empty groups are ignored. 2. **Routing** — `metadataTags` applied to the run and `watchOnly: true` which records a match but launches no work. -3. **Target** — what to create. `type` is one of `mission`, +3. **Target** — what to create. `type` is one of `employee_session`, `worker_run`, `pr_resolution`, `review_gate`. Other target fields set executor kind (`cto`/`employee`/`worker`), run mode (`autopilot`/`assisted`/`manual`), lane selection @@ -178,7 +172,7 @@ Core Linear services on desktop - `linearOAuthService.ts` — OAuth authorization flow - `linearClient.ts` — GraphQL client wrapper - `linearIssueTracker.ts` — normalization into `NormalizedLinearIssue` -- `linearTemplateService.ts` — mission/session template resolution +- `linearTemplateService.ts` — session template resolution - `linearWorkflowFileService.ts` — YAML workflow files under `.ade/workflows/linear/**`. Every `save(config)` call invokes `ensureSharedAdeProjectScaffold(projectRoot)` first so a project that @@ -340,7 +334,7 @@ Headless ADE CLI mode: fail fast when agent execution is requested. - `apps/ade-cli/src/adeRpcServer.ts` registers the Linear JSON-RPC tools at `listLinearWorkflows`, `getLinearRunStatus`, `resolveLinearRunAction`, - `cancelLinearRun`, `routeLinearIssueToCto`, `routeLinearIssueToMission`, + `cancelLinearRun`, `routeLinearIssueToCto`, `routeLinearIssueToWorker`, `rerouteLinearRun`, `getLinearSyncDashboard`, `runLinearSyncNow`, `listLinearSyncQueue`, `resolveLinearSyncQueueItem`, `getLinearWorkflowRunDetail`. @@ -354,8 +348,8 @@ Deeper reading: ## Lane attachment, commit references, and PR magic words -The Linear pipeline above is fully autonomous: it runs missions / -chats / workers without the human ever opening a lane manually. Most +The Linear pipeline above is fully autonomous: it runs chats / +workers without the human ever opening a lane manually. Most day-to-day developer work, though, starts the other way around — the human picks a Linear ticket and creates a lane to work on it. ADE exposes that path in three places that all share the same primitives: @@ -513,7 +507,7 @@ once configured, but: `status: "failed"` with the message *"Headless ADE CLI mode does not support worker-backed Linear targets yet."* Workflows targeting `worker_run` are not a supported headless path; use - `employee_session`, `mission`, or `pr_resolution` instead. + `employee_session` or `pr_resolution` instead. - **OAuth client config is per-app, not per-project.** Token storage is `storageScope: "app"` in `LinearConnectionStatus`. Switching projects does not change which Linear workspace is attached unless the token is diff --git a/docs/features/linear-integration/dispatch-and-sync.md b/docs/features/linear-integration/dispatch-and-sync.md index 0c0e6423d..51200bae3 100644 --- a/docs/features/linear-integration/dispatch-and-sync.md +++ b/docs/features/linear-integration/dispatch-and-sync.md @@ -11,7 +11,7 @@ delegation queue, and outbound API client — runs inside the runtime daemon (`ade serve`) that owns the project. For local projects the local daemon hosts them; for remote projects the remote runtime hosts them. The headless ADE CLI in `headlessLinearServices.ts` constructs -the same services so Linear can drive missions / chats / PRs without +the same services so Linear can drive chats / PRs without the desktop UI running. PR creation paths are local-by-default because they shell out to git; they still execute on the runtime host. @@ -181,14 +181,13 @@ A run's steps advance via `currentStepIndex`. For each step type: outbound calls are wrapped in try/catch; failures log a warning and the run advances so transient Linear API errors do not derail the run. - `launch_target` — creates the target artifact. Dispatches to - `missionService` (mission), `agentChatService.createSession` / - `ensureIdentitySession` (employee session), `workerAgentService` - (worker run), or `prService` (PR resolution). The target id is - stored on the run (`linkedMissionId`, `linkedSessionId`, - `linkedWorkerRunId`, `linkedPrId`). + `agentChatService.createSession` / `ensureIdentitySession` + (employee session), `workerAgentService` (worker run), or + `prService` (PR resolution). The target id is stored on the run + (`linkedSessionId`, `linkedWorkerRunId`, `linkedPrId`). - `wait_for_target_status` — the run parks in `waiting_for_target`. - `runtime_completed` accepts a missionService/workerAgent runtime - success; `explicit_completion` requires the target to call back with + `runtime_completed` accepts worker runtime success; + `explicit_completion` requires the target to call back with an explicit completion (agent chat session end, worker run finalization). - `wait_for_pr` — the run parks in `waiting_for_pr` until the linked PR @@ -347,7 +346,7 @@ ADE CLI action surface from `apps/ade-cli/src/adeRpcServer.ts`: - `listLinearWorkflows`, `getLinearRunStatus`, `resolveLinearRunAction`, `cancelLinearRun`, `routeLinearIssueToCto`, - `routeLinearIssueToMission`, `routeLinearIssueToWorker`, + `routeLinearIssueToWorker`, `rerouteLinearRun` - `getLinearSyncDashboard`, `runLinearSyncNow`, `listLinearSyncQueue`, `resolveLinearSyncQueueItem`, `getLinearWorkflowRunDetail` @@ -374,7 +373,7 @@ ADE CLI action surface from `apps/ade-cli/src/adeRpcServer.ts`: headless `workerHeartbeatService` always returns `status: "failed"` with the message *"Headless ADE CLI mode does not support worker-backed Linear targets yet."* Design headless - workflows around `mission`, `employee_session`, or `pr_resolution` + workflows around `employee_session` or `pr_resolution` targets. - **`fresh_issue_lane` requires a lane-safe base ref.** When the target wants a fresh lane per issue, lane creation uses the base branch diff --git a/docs/features/linear-integration/workflow-presets.md b/docs/features/linear-integration/workflow-presets.md index da6963689..a0fc3e185 100644 --- a/docs/features/linear-integration/workflow-presets.md +++ b/docs/features/linear-integration/workflow-presets.md @@ -85,7 +85,6 @@ regenerated. ```ts export function defaultCompletionContract(targetType) { - if (targetType === "mission") return "wait_for_runtime_success"; if (targetType === "pr_resolution") return "wait_for_pr_created"; if (targetType === "employee_session" || targetType === "worker_run") { return "wait_for_explicit_completion"; @@ -96,8 +95,6 @@ export function defaultCompletionContract(targetType) { The shape of the preset flows from this: -- **Mission**: starts → moves to In Progress → launches mission → waits for - runtime success → optional review/notify → complete. No PR gate. - **Employee session**: starts → In Progress → creates chat session → waits for explicit completion (chat ends or user marks done) → optional review/notify → complete. @@ -126,13 +123,12 @@ type WorkflowPresetOptions = { Defaults when options are omitted: - `id` from `workflowIdForTargetType(targetType)`: - - `mission` → `"assigned-mission-run"` - `employee_session` → `"assigned-employee-session"` - `worker_run` → `"assigned-worker-run"` - `pr_resolution` → `"assigned-pr-resolution"` - `review_gate` → `"assigned-review-gate"` - `name` from `defaultWorkflowName(targetType)`. Examples: - `"Assigned employee -> review handoff"`, `"Mission autopilot"`, + `"Assigned employee -> review handoff"`, `"Human review gate"`. - `triggers.assignees` defaults to `["CTO"]`. - `triggers.labels` defaults to `["workflow:default"]`. @@ -190,8 +186,7 @@ Merges `planPatch` into the current plan and reconstructs the step array: 4. For review gate targets, emit `request_human_review` immediately. 5. For non-review-gate targets, emit `wait_for_target_status` with the target status inferred from `completionContract` and `target.type`. - Missions always use `runtime_completed`; employee/worker sessions - default to `explicit_completion`. + Employee/worker sessions default to `explicit_completion`. 6. If supervisor mode is `after_work` or `before_pr`, emit `request_human_review` here. 7. If the contract uses a PR gate (`wait_for_pr_created` or diff --git a/docs/features/missions/README.md b/docs/features/missions/README.md deleted file mode 100644 index ca2c37096..000000000 --- a/docs/features/missions/README.md +++ /dev/null @@ -1,232 +0,0 @@ -# Missions - -A mission is ADE's structured, multi-step execution primitive. It wraps a user goal in a durable run/step/attempt/intervention/artifact state machine while a coordinator agent plans, delegates, monitors, and finalizes. Missions are the heavy-orchestration path — the lighter paths are a CTO chat turn or an automation rule. - -The runtime is feature-rich but the mission launcher and page shell now follow a staged-load model so the surface stays responsive even while orchestrator metadata warms up. - -## Runtime ownership - -Missions live in whichever runtime daemon owns the project. The coordinator agent loop, planner workers, implementation/testing/validation workers, intervention queue, recovery loop, and result-lane finalization all execute inside `ade serve`. For local projects that is the local daemon; for remote projects it is the remote runtime over SSH-tunneled JSON-RPC. The desktop renderer's mission UI (`MissionsPage`, `MissionDetailView`, chat channels, plan editor) is purely a view: it reads runs/steps/attempts/events through the active runtime binding, sends control RPCs (start, cancel, steer, intervene, approve), and renders coordinator/worker chat threads. - -Caveats that follow from "runtime owns missions": - -- Worker provider availability follows the runtime host. A remote Linux runtime cannot launch a worker that requires the macOS-only iOS Simulator; that worker has to run on a Mac runtime. -- Mission artifacts (including computer-use proof) write to the runtime host's project artifacts directory. Remote runs store proof on the remote machine. - -## Source file map - -### Core services (apps/desktop/src/main/services/) - -- `missions/missionService.ts` — the mission persistence layer. Missions, steps, interventions, artifacts, events. `createMission`, `addMissionStep`, `addIntervention`, `addArtifact`, `getMissionDetail`, `getMissionEvents`. Handles terminal-status guards (`TERMINAL_MISSION_STATUSES`), treats only `planning` / `in_progress` as dashboard-active, clears stale lane ownership when a referenced mission no longer exists, and releases mission-owned lanes during deletion. -- `missions/phaseEngine.ts` — phase cards, profiles, built-in phases (`planning`, `development`, `integration`, `testing`, `validation`, `closeout`, legacy `prAndConflicts`), `applyPhaseCardsToPlanSteps`, `validatePhaseSequence`, `groupMissionStepsByPhase`. `isDevelopmentLikePhaseKey` / `resolveDevelopmentPhaseKey` keep renamed implementation phases executable, while ordering constraints (`mustFollow`, `mustPrecede`, `mustBeFirst`, `mustBeLast`) keep Integration after Development and Closeout last. -- `missions/missionPreflightService.ts` — preflight checks: lane claim ownership, knowledge sync, human work digest, computer-use readiness, phase structure, budget estimates. -- `orchestrator/orchestratorService.ts` — runtime state machine: runs, steps, attempts, claims, artifacts, gate reports, timeline events. ~8000 LOC. Owns the tick loop and `completeAttempt`. -- `orchestrator/aiOrchestratorService.ts` — façade that ties missionService + orchestratorService + AI integration together. Owns `pauseMissionWithIntervention`, `steerMission`, recovery, `check_finalization_status`. -- `orchestrator/coordinatorAgent.ts` — the coordinator brain. Long-running AI agent that plans, spawns workers, monitors, advances phases, and finalizes. Lifecycle states: `booting`, `analyzing_prompt`, `fetching_project_context`, `launching_planner`, `waiting_on_planner`, `planner_launch_failed`, `stopped`. -- `orchestrator/coordinatorTools.ts` — tools the coordinator can call (plan DAG, spawn workers, check status, ask the user, finalize). -- `orchestrator/planningQuestionPolicy.ts` — shared policy that decides when Planning must ask and resolve a blocking question before the phase can exit. Used by the coordinator agent, coordinator tools, and run completion guards. -- `orchestrator/coordinatorSession.ts` — per-coordinator session state. -- `orchestrator/missionLifecycle.ts` — mission run start, approve, cancel, cleanup, steer, sync, lane provisioning, team manifest synthesis. -- `orchestrator/missionStateDoc.ts` — `.ade/missions//state.md` and coordinator checkpoint files. -- `orchestrator/missionBudgetService.ts` — budget telemetry: estimates, rollups by phase/worker/provider, hard caps, pressure levels. -- `orchestrator/executionPolicy.ts` — default `MissionExecutionPolicy`, merge rules (mission > project > fallback), completion evaluation, run/step validation. -- `orchestrator/adaptiveRuntime.ts` — `classifyTaskComplexity` (trivial/simple/moderate/complex), parallelism scaling, model downgrade. -- `orchestrator/workerDeliveryService.ts` — message delivery pipeline between coordinator and worker chats; retry, idempotency, in-flight leases. -- `orchestrator/workerTracking.ts` — post-attempt artifact extraction and the planning-question intervention path. `planner_natural_question` opens a `manual_input` intervention, and `planningQuestionPolicy.ts` still gates required planning clarification with `planner_required_question_missing` when configured. -- `orchestrator/delegationContracts.ts` — contracts between coordinator and workers (scope, allowed tools, handoff shape). -- `orchestrator/runtimeEventRouter.ts` — routes events from worker sessions and CLI output into the coordinator. -- `orchestrator/metaReasoner.ts` — higher-level reasoning for coordinator choices. -- `orchestrator/metricsAndUsage.ts` — token / cost accounting per run. -- `orchestrator/recoveryService.ts` — tracked session state, recovery iterations. -- `orchestrator/stepPolicyResolver.ts` — step-level policy merging, autopilot config, file-claim scope. -- `orchestrator/baseOrchestratorAdapter.ts` — `buildFullPrompt` + shell escaping. -- `orchestrator/providerOrchestratorAdapter.ts` — provider-specific worker launches (Claude CLI, Codex CLI, ADE CLI). -- `orchestrator/promptInspector.ts` — coordinator / planning / worker prompt inspectors for the mission detail UI. -- `orchestrator/missionStateDoc.ts` — state doc and coordinator checkpoint read/write. -- `orchestrator/teamRuntimeConfig.ts` / `teamRuntimeState.ts` — team manifest and runtime state. -- `orchestrator/permissionMapping.ts` — mission permission config to provider-specific tool permissions. -- `orchestrator/orchestratorQueries.ts` — row types, helpers for mapping DB rows to typed objects, normalization. -- `apps/ade-cli/src/cli.ts` — typed `ade missions` command group (`list`, `create`, `launch`, `start`, `resume`, `show`, `runs`, `graph`, `watch`) plus phase/planned-step JSON payload options for headless or socket-backed mission operations. Routes through the active runtime daemon; with `--socket` it talks to the desktop's local daemon, otherwise it spins up a headless project scope. -- `apps/ade-cli/src/multiProjectRpcServer.ts` — exposes mission lifecycle and run-graph reads as project-scoped JSON-RPC actions consumed by both the desktop preload bridge (for remote bindings) and the CLI. - -### Renderer - -All under `apps/desktop/src/renderer/components/missions/`: - -- `MissionsPage.tsx`, `MissionsHomeDashboard.tsx`, `MissionSidebar.tsx` — list shell, dashboard cards (active / completed split), sidebar entry, and `missionId` query-param synchronization for deep links. Active mission entries surface phase progress, an "N active" / "0 active" / "no active workers" worker count, and elapsed time. Recent dashboard rows always use a concrete View action because selecting a row does not rerun or retry the mission. -- `CreateMissionDialog.tsx` + `MissionCreateDialogHost.tsx` — staged-load create dialog. `phasesMatchProfile`, `applyModelToMissionPhases`, `withLowReasoning`, and `applyCodexLowFullAutoPresetToDraft` keep the phase override draft in sync with model/profile changes; `validatePhaseOrder` accepts development-like phase keys (`development_*`, `*_development`, `implementation`, `build`, `code`) so user-renamed phases still pass ordering checks. Defaults from mission settings include the default orchestrator model, permission config, and Smart Budget config. -- `MissionDetailView.tsx`, `MissionTabContainer.tsx`, `MissionHeader.tsx`, `MissionRunPanel.tsx`, `MissionControlOfficePanel.tsx`, `MissionActivePhasePanel.tsx`, `MissionLogsTab.tsx`, `MissionArtifactsTab.tsx`, `PromptInspectorCard.tsx` — mission detail tabs and chrome. -- `MissionChatV2.tsx`, `ChatChannelList.tsx`, `ChatMessageArea.tsx`, `ChatInput.tsx`, `MissionThreadMessageList.tsx`, `chatFilters.ts`, `missionChatChannelModel.ts` — conversations tab. Channel rows expose accessible names through `formatChannelAccessibleName` (e.g. `"Orchestrator, orchestrator channel"`); legacy badge/badge-color props were removed in favor of the accessible label. -- `PlanTab.tsx`, `PhaseCardEditor.tsx`, `missionPhaseDefaults.ts` — plan review and phase-card editing surface (validation gate evidence checklist, phase key editing for custom phases). Display-only task records are filtered out of executable step lists so task-planning rows do not inflate phase progress. -- `missionControlViewModel.ts`, `missionFeedPresentation.ts`, `missionThreadEventAdapter.ts`, `missionInterventionRouting.ts`, `missionWorkerPresentation.ts`, `missionHelpers.ts`, `chatFilters.ts` — pure derivations consumed by the UI. -- `useMissionRunView.ts`, `useMissionPolling.ts`, `useMissionsStore.ts`, `missionDialogDataCache.ts`, `missionCreateDialogStore.ts`, `MissionActiveContext.tsx` — store + hooks. `MissionTabContent` receives `runView` as a prop (computed once at the page level via `useMissionRunView`) instead of subscribing locally, so the panels share one polling source. The store is built with `createStore()(createMissionsState)` and `MissionsPage` instantiates its own copy via `createMissionsStore()` inside a `MissionsStoreProvider`, so multiple project tabs each get their own missions selection / chat-jump / prompt-inspector state. Components reach the scoped store via `useMissionsStoreApi()` for imperative `getState()` access; `useMissionsStore(selector)` reads from whichever store the surrounding provider holds (falling back to a module-level root store for any callers outside a provider). `MissionActiveContext` carries the page-level `active?: boolean` down so `MissionsWorkspace`, `MissionsProductionGate`, `MissionHeader`, `MissionChatV2`, `CompactUsageMeter`, `InterventionPanel`, and `OrchestratorActivityFeed` can short-circuit every polling / event-subscription effect when the missions surface is not the foreground project tab. -- `apps/desktop/src/renderer/components/chat/missionControlTextTools.ts` — coordinator-side rewrite that turns ADE mission-control text-tool envelopes into rendered tool events for `MissionThreadMessageList`. `isMissionControlToolName(name)` is the canonical predicate; the `mission-thread` presentation mode auto-rewrites events on mount via `rewriteMissionControlTextToolEvents`. -- `apps/desktop/src/renderer/perf/scenarios/missions.ts` — mission-tab performance scenario used by renderer perf probes. It seeds a large mission fixture, drives the missions route, and records tab-specific interaction timing. - -### Validation contract - -- `docs/validation-contract-m1-m2.md` — the behavioral test spec. Canonical list of required invariants with VAL-XXX identifiers. - -## Runtime contract - -### Planning is mandatory - -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. -3. Delegates to a planning worker (`launching_planner` -> `waiting_on_planner`). -4. Explicitly advances phases as plan steps complete. - -A planning-startup guard prevents non-ADE tool drift during the prep phase. If the coordinator detects tool calls that don't belong to the planning setup (e.g. arbitrary ADE CLI calls during context fetch), they are trapped and routed into explicit recovery rather than silent fallback. - -Phase cards can mark `askQuestions.requiredBeforeExit`. When enabled, Planning cannot transition to the next executable phase until a planner-owned manual-input question has been resolved or explicitly skipped. ADE also promotes that requirement when the mission prompt or planning instructions clearly demand required planning questions before implementation. Phase-approval interventions are separate from planning questions, so approval gates do not accidentally satisfy or block the clarification requirement. - -### Planner launch reliability - -Planner launches track attempts and classify failures via `classifyPlannerLaunchFailure`: - -- Transient (network, timeout, resource contention) -> automatic retry with structured intervention logging. -- Permanent (config errors, missing capabilities) -> explicit intervention for the operator. -- All failure categories appear in the run timeline for observability. - -### Root propagation - -Worker tools resolve DB state from the canonical repo root while file access stays scoped to the lane worktree. This applies to both desktop-launched and headless-launched workers — the mission state query reads from the right database regardless of `cwd`, and file writes are restricted to the worktree. Validation target: `VAL-ISO-001` / `VAL-ISO-002`. - -### Closeout contract: result-lane - -All missions end in a **result lane** — one consolidated lane that contains the mission's changes. The coordinator assembles worker outputs into this lane and stops before PR creation. The user decides when to open a PR. - -The previous multi-strategy model (integration / per-lane / queue / manual) is gone. `finalizationPolicyKind: "result_lane"` is set on every new mission; the `CreateMissionDialog` no longer exposes PR-strategy selection. The coordinator exposes a `check_finalization_status` tool that reads the mission state doc and reports contract satisfaction, execution completeness, and result-lane readiness. - -### Terminal-status regression guard - -When a mission reaches terminal status (`completed`, `failed`, `cancelled`), `transitionMissionStatus` refuses transitions back to non-terminal states. Attempts are logged and silently skipped. This prevents stale coordinator events from reopening completed missions. - -### Step execution resilience - -- **PR merge fallback** — 3-tier retry: attempt merge, retry on transient, fall back to draft PR, then request user intervention. Failed merges return `blocked`, not `failed`, so the run stays recoverable. -- **Stagnation detection** — Agents producing no output are tracked as potentially stagnant; elapsed silence is surfaced so the coordinator can intervene. -- **Review wait timeout** — Human review steps time out after 48 hours with reason `review_timeout`. -- **Turn-level timeout** — Individual agent turns are capped at 5 minutes via the abort infrastructure. -- **Autopilot timeout** — Autopilot polls every 15 seconds (single configurable constant, up from 5s). - -### Planning question handling - -A planner can pause the run by emitting an `awaiting_user_input` step -with `source === "planner_natural_question"` and a non-empty -`question`. `workerTracking.extractAndRegisterArtifacts` translates -that into a single `manual_input` intervention (`reasonCode: -"planner_natural_question"`) and a matching `pauseRun` so the -coordinator stops until the user answers. - -Required planning clarification is still enforced separately by -`planningQuestionPolicy.ts`: if a planning phase exits without a -required answer, ADE records `planner_required_question_missing` and -keeps the run paused. Both `planner_natural_question` and -`planner_required_question_missing` are valid intervention reason codes. - -### Mission step bidirectional sync - -`syncRunStepsFromMission()` pulls user-initiated mutations (cancel, skip) from the mission state back into orchestrator run state. The orchestrator picks the change up on its next tick. - -`syncMissionStepsFromRun()` is the other direction. It pairs orchestrator -run steps to mission steps via `runStep.missionStepId`, falling back to -`metadata.orchestratorStepId` / `metadata.stepKey` / matching titles so -steps recorded before the explicit join column was populated still -synchronize. - -After step/phase sync, `syncMissionFromRun()` must re-read mission -detail before deriving a non-terminal mission status. Sync can add or -resolve interventions, so deriving status from a stale mission snapshot -can overwrite `intervention_required` back to `in_progress`. Only an -explicit `nextMissionStatus` bypasses that re-derive. - -`ensureTerminalFailedRunIntervention()` then guarantees a terminal -`failed` run cannot leave the mission in a clean `failed` status without -a matching `failed_step` intervention: if no open `failed_step` row -exists, it adds one (with reason code `terminal_run_failed_step`), -moves the mission to `intervention_required` even though the run itself -is terminal, and `deriveMissionStatusFromRun` checks blocking -interventions before honouring `run.status === "failed"`. - -### Cascade cleanup - -On terminal state, the runtime calls `cleanupTeamResources()` (best-effort). Worker sessions, temporary worktrees, and team-scoped resources are torn down so they don't leak across runs. - -### Mission detail warnings - -Loading a mission returns `MissionDetailWarning[]` for records with invalid JSON in metadata. Each warning has `code` (`invalid_json` | `truncated_events`), `source`, `field`, `message`. The UI surfaces warnings instead of silently dropping records. - -### Mission event pagination - -`getMissionEvents({ missionId, limit?, before? })` returns `MissionEventsPage` with `events`, `nextCursor`, `hasMore`, and any deserialization `warnings`. Default page size 200; cursor encodes `createdAt::id`. Mission detail loads recent events eagerly and older events on demand. - -### Queue deduplication - -Queued missions use a claim-token mechanism to prevent duplicate starts from stale orchestrator events. `claimQueuedMissionStart()` acquires an exclusive token under `BEGIN IMMEDIATE`. Stale after 2 minutes. `queue_claim_token` + `queue_claimed_at` columns on the missions table support this. Missions with `autostart: false` in launch metadata are excluded from automatic queue processing. - -## Mission detail surface - -Five flat tabs (`MissionTabNavigation` / `MissionTabContent` in `MissionTabContainer.tsx`): - -- **Overview** — `MissionControlOfficePanel` summary card on top, then `MissionRunPanel` with phase progression and active workers. The phase chrome stays compact in the chat-focused layouts; non-chat tabs render the run-completion banner above the panels. -- **Conversations** — `MissionChatV2` shell with `ChatChannelList` (orchestrator + per-worker channels, each carrying `laneId`), `ChatMessageArea` rendering paginated thread messages via `MissionThreadMessageList`, and `ChatInput`. Internal coordinator-prompt boilerplate (`Start mission coordination.`, `Continue mission coordination.`, `RECOVERED TOOL CALLS`, the ADE Mission-Control transport block) is suppressed; mission-control text-tool calls are rewritten through `rewriteMissionControlTextToolEvents` so the user-facing transcript reads clean. Long worker reports with embedded `Changed Files` / `Tests Run` JSON are cleaned by `cleanMissionThreadText` and the changed file list is appended as plain bullets instead. -- **Plan** — planner review summary (objective, strategy, complexity, assumptions, risks) plus the phase-grouped step list with `PhaseCardEditor` cards. Each card edits the phase key, name, description, instructions, model + reasoning level, ask-questions toggle, required-before-exit clarification policy, and the validation gate (`tier`, `required`, criteria, `evidenceRequirements` checklist of `planning_document` / `research_summary` / `changed_files_summary` / `test_report` / `review_summary` / `risk_notes` / `final_outcome_summary` / `screenshot` / `browser_verification` / `video_recording` / `browser_trace` / `console_logs`). Built-in phases keep their key locked; custom phases get a slugified-on-input phase key field. Long phase step lists compact by default so large AutoResearch plans do not duplicate every step in both the phase grouping and the executable list. -- **Timeline** — runtime timeline (run/step/attempt events, interventions, review decisions) is summarized by `OrchestratorActivityFeed`. The raw `MissionLogsTab` view is gated behind a "Show raw logs" toggle so the default surface stays curated; activating the toggle reveals the legacy structured event list with the same intervention focus support. -- **Artifacts** — `MissionArtifactsTab` rendering orchestrator artifact groups (`buildMissionArtifactGroups`) plus broker-managed computer-use artifacts. - -Tab navigation uses ARIA `role="tablist"` / `role="tab"` / `role="tabpanel"` with matching `aria-controls` / `aria-labelledby` ids. Tab buttons participate in keyboard navigation (`tabIndex={isActive ? 0 : -1}`). - -## Mission page loading - -Staged loads. The mission list refreshes immediately; dashboard, settings, and model capability fetches are delayed incrementally. The create-dialog data is prewarmed in the background through `prewarmCreateMissionDialogCache()` instead of duplicate page-level phase fetches. Live-refresh behavior is narrower: mission events refresh list/dashboard on a short coalesced debounce; orchestrator events refresh only the selected mission view on a longer debounce; backgrounded tabs skip most work until the renderer is visible. Selecting a mission updates `?missionId=` and opening a mission from a deep link selects it once the list has loaded. - -## Mission creation - -The `CreateMissionDialog` prewarms phase profiles and AI model availability. It does not include a PR-strategy selector (result-lane closeout is the only option). New launch drafts inherit Smart Budget defaults from `ai.orchestrator.smartBudget` so mission-specific model configs and preflight estimates use the same thresholds the settings dialog shows. Budget telemetry is conditional: - -- Smart budget telemetry only loads when Smart Budget is enabled. -- Subscription budget telemetry only loads when relevant providers are selected. -- API usage aggregation only loads when API-model budgeting is active. - -Heavy sections (budget, team runtime, permissions, computer-use controls) mount after the dialog settles. `MissionSettingsDialog` only mounts when open; the host unmounts closed dialog content rather than leaving heavy hidden trees. - -## Mission preflight - -Preflight (`missionPreflightService.ts`) checks the current project and runtime state before launch, including: - -- lane-claim ownership — blocks launches when the selected lane is archived, rebasing, already owned by another live mission, or already recorded as a result lane. -- computer-use readiness (required proof kinds for the selected phase profile, available backends). -- mission policy (execution, finalization, computer-use, budget caps) and phase structural ordering. -- budget estimates (`MissionPreflightBudgetEstimate`). - -Preflight warnings surface in the launch dialog but do not block launch unless they are hard-blocking (missing required proof, missing model credentials, no lane selected). - -## Cross-links - -- `orchestration.md` — coordinator, steps, graph, DAG mutations, delegation contracts, fan-out. -- `validation-gates.md` — behavioral assertions from `docs/validation-contract-m1-m2.md`. -- `workers.md` — mission workers: pool, concurrency, delegation, role isolation. -- `../cto/linear-integration.md` — a `target.type === "mission"` Linear workflow dispatches through `aiOrchestratorService`. -- `../automations/README.md` — automations can launch missions via the `mission` execution surface. -- `../computer-use/README.md` — mission preflight, run monitoring, and artifact review all consume broker-managed artifacts. - -## Current product contract - -- Mission list stays usable immediately. -- Don't fetch launch-only metadata until the user is actually launching. -- Don't compute budget telemetry unless budget controls are active. -- Mount advanced launcher/settings UI only when needed. -- Live mission updates focus on the selected mission, not the whole dashboard. -- Preserve the durable run/step/artifact model under a lighter UI shell. - -## Gotchas and fragile areas - -- **`missionLifecycle.ts` uses the deps-injection pattern** because the extraction from `aiOrchestratorService.ts` is partial — many functions re-declare type contracts and get their implementations via the deps arg. Don't assume the file contains the full logic; follow the imports back to `aiOrchestratorService`. -- **Dual intervention creation paths** — `orchestratorService.completeAttempt()`, `runtimeEventRouter.routeEventToCoordinator()`, and `ensureTerminalFailedRunIntervention()` (called from `syncMissionFromRun`) all open `failed_step` interventions. `VAL-INTV-001` / `VAL-INTV-002` assert that they dedupe. Any new code path that creates `failed_step` interventions must re-check existing-open-intervention. -- **Budget pause consistency** — token budget in `completeAttempt` and hard cap in `spawn_worker` must both flow through `pauseMissionWithIntervention`. See `VAL-BUDGET-001`. -- **`tickRun` must skip budget-paused runs** — `VAL-BUDGET-002`. Any refactor that replaces the skip check must preserve the invariant. -- **`finalizationPolicyKind` is always `"result_lane"`** for newly created missions. Don't re-introduce PR-strategy UI without coordinating with the closeout contract. -- **Mission `_snapshotHash` parity** with Linear is separate — don't confuse the two. Mission artifacts use orchestrator artifact tables; Linear workflow artifacts attach via the broker. diff --git a/docs/features/missions/orchestration.md b/docs/features/missions/orchestration.md deleted file mode 100644 index e17be6e93..000000000 --- a/docs/features/missions/orchestration.md +++ /dev/null @@ -1,258 +0,0 @@ -# Orchestration - -The orchestrator is the in-runtime engine that drives missions. It owns runs, steps, attempts, claims, artifacts, gate reports, timeline events, and the coordinator-agent session that turns a natural-language goal into a multi-step plan and execution DAG. Every part of the orchestrator runs inside whichever runtime daemon owns the project (local `ade serve` for local projects, the remote runtime over SSH for remote projects); the desktop UI is a viewer over the runtime's RPC surface. - -Worker spawn paths use whatever provider CLIs are installed on the runtime host. A remote Linux runtime can spawn `claude-local` and `codex-local` workers but cannot spawn anything that needs macOS-only tooling (e.g. iOS simulator drivers); plan accordingly when authoring missions for remote-hosted projects. - -## Source file map - -All in `apps/desktop/src/main/services/orchestrator/`. Files in this directory are loaded by the runtime daemon's project scope (and by the desktop main process for local projects); the path reflects the source tree, not the host process. - -- `orchestratorService.ts` — row-level persistence and the low-level run state machine. `tickRun`, `completeAttempt`, claim acquisition, gate reports. ~8000 LOC. The most delicate file in the service layer. -- `aiOrchestratorService.ts` — the façade used by the rest of the app and by the AI surfaces. Wires mission + orchestrator + AI integration + budget + conflict services. Owns top-level flows: `pauseMissionWithIntervention`, `steerMission`, run finalization, recovery. -- `coordinatorAgent.ts` — the coordinator brain. Provider-agnostic AI agent that runs until the mission is terminal. Owns planning lifecycle, tool execution, compaction, checkpointing. -- `coordinatorTools.ts` — the coordinator's tool surface: `spawn_worker`, `check_status`, `send_message_to_worker`, `ask_user`, `finalize_run`, `check_finalization_status`, `mark_phase_complete`, `request_lane`, `request_human_review`, DAG mutation helpers. -- `planningQuestionPolicy.ts` — shared Planning clarification policy. It normalizes phase prompts, mission prompts, and planner-owned manual-input interventions so `coordinatorAgent`, `coordinatorTools`, and `orchestratorService.completeAttempt` agree on when Planning can exit. -- `coordinatorSession.ts` — coordinator-session lifecycle (start, resume, checkpoint). -- `coordinatorEventFormatter.ts` — formats runtime events into human-readable messages for the coordinator chat thread. -- `missionLifecycle.ts` — mission-specific run start, approve, cancel, cleanup, steer, sync, lane provisioning, team manifest synthesis. Partial extraction from `aiOrchestratorService`; uses the deps-injection pattern so closures still live in the façade. -- `missionStateDoc.ts` — `.ade/missions//state.md` + coordinator checkpoint files (`checkpoint.json`). Used for resume and for the `check_finalization_status` tool. -- `executionPolicy.ts` — default `MissionExecutionPolicy`, merge rules (mission metadata > project config > fallback), run completion validation, phase-to-executor mapping. Completion evaluates renamed implementation phases, excludes auto-spawned validation-proof steps from validation obligations, ignores non-executable Closeout gates, and can count succeeded display-only phase records only when they correspond to configured phase targets. -- `adaptiveRuntime.ts` — `classifyTaskComplexity` heuristics (`trivial` / `simple` / `moderate` / `complex`), parallelism scaling, model downgrade rules. -- `delegationContracts.ts` — `DelegationContract` types and helpers. Each worker delegation carries a scope, allowed tools, and handoff shape. -- `workerDeliveryService.ts` — message delivery pipeline: coordinator -> worker chat, worker -> coordinator. Retry, idempotency, in-flight leases. Imports `WORKER_MESSAGE_RETRY_BUDGET`, `WORKER_MESSAGE_INFLIGHT_LEASE_MS`, `WORKER_MESSAGE_INFLIGHT_STALE_FAIL_MS`. -- `runtimeEventRouter.ts` — routes events from worker sessions and CLI output into the coordinator. Classifies events, creates interventions, triggers coordinator evaluation. -- `metaReasoner.ts` — higher-level reasoning helpers for coordinator decisions. -- `metricsAndUsage.ts` — token and cost accounting; `estimateTokenCost`. -- `recoveryService.ts` — tracked session state, recovery iteration policy (`DEFAULT_RECOVERY_LOOP_POLICY`). -- `workerTracking.ts` — worker session tracking, per-attempt artifact extraction (`extractAndRegisterArtifacts`), planning-phase plan-artifact persistence gate, `planner_plan_missing` intervention auto-resolution on successful re-planning, and the `planner_natural_question` `manual_input` intervention path (with matching `pauseRun`) when the planner emits an `awaiting_user_input` step. `planningQuestionPolicy.ts` still enforces required planning clarification and can record `planner_required_question_missing`. -- `stepPolicyResolver.ts` — `ResolvedOrchestratorRuntimeConfig`, step-level policy merging, autopilot config, file-claim scope (`doFileClaimsOverlap`, `doesFileClaimMatchPath`), repo-relative path normalization. -- `baseOrchestratorAdapter.ts` — `buildFullPrompt` (the worker prompt builder), shell escaping, inline decoding. Worker runtime is now `tracked_session | in_process` only; the legacy `managed_chat` branch was retired with the worker-prompt simplification. -- `providerOrchestratorAdapter.ts` — provider-specific launchers for Claude CLI, Codex CLI, and managed OpenCode-backed execution. -- `promptInspector.ts` — coordinator / planning / worker prompt inspectors for the mission UI. -- `runtimeInterventionsSteeringErrors.test.ts` — runtime intervention behavior tests. -- `planningFlowAndHandoffs.test.ts`, `planningGapsFixes.test.ts` — planning lifecycle tests. -- `hardeningMissions.test.ts` — mission hardening regression suite. -- `knowledgeConflictsBrowserCto.test.ts` — knowledge and browser-conflict tests. -- `stateCoherence.test.ts` — state coherence invariants (`VAL-STATE-*`). -- `worktreeIsolation.test.ts` — worktree isolation invariants (`VAL-ISO-*`). -- `orchestrationRuntime.test.ts` — end-to-end runtime integration tests. -- `orchestratorConstants.ts` — `DEFAULT_RECOVERY_LOOP_POLICY`, `DEFAULT_CONTEXT_VIEW_POLICIES`, `DEFAULT_ROLE_ISOLATION_RULES`, `DEFAULT_INTEGRATION_PR_POLICY`. -- `orchestratorContext.ts` — `OrchestratorContext` type (shared runtime state, including the `cancelingRuns: Set` cancellation guard the AI failure-diagnosis fallback consults so a canceled run never has a diagnosis intervention written after the fact), pure helpers, constants (`TERMINAL_STEP_STATUSES`, `STEERING_DIRECTIVES_METADATA_KEY`, etc.), `parseJsonRecord`, `isRecord`, `nowIso`. `isDisplayOnlyTaskStep` is the canonical predicate for steps that should be hidden from execution flow (`metadata.isTask === true`, `metadata.displayOnlyTask === true`, or `metadata.stepType === "task"` without a coordinator-spawned worker); `filterExecutionSteps` delegates to it. -- `orchestratorQueries.ts` — row-to-typed-object mapping: `toRun`, `toStep`, `toAttempt`, `toClaim`, `toContextSnapshot`, `toHandoff`, `toArtifact`, `toTimelineEvent`, `toRuntimeEvent`, `toGateReport`; `normalizeEnvelope`, `classifyBlockingWarnings`, `validateStepGraphIntegrity`. -- `permissionMapping.ts` — `MissionProviderPermissions` -> provider allowed-tools; `mapPermissionToInProcess`. -- `teamRuntimeConfig.ts` / `teamRuntimeState.ts` — team manifest and runtime state. - -## Coordinator agent - -The coordinator is a provider-agnostic AI agent that runs until the mission is terminal. Its dependencies (`CoordinatorAgentDeps`): - -- `orchestratorService`, `missionService`, `projectConfigService`, optional `getMissionBudgetStatus`. -- `runId`, `missionId`, `missionGoal`, `modelId`. -- Project context (`projectRoot`, `projectDocPaths`, `projectKnowledge`, `fileTree`). -- Available providers. -- Phase cards (for the coordinator prompt). -- Callbacks: `onDagMutation`, `onCoordinatorMessage`, `onCoordinatorEvent`, `onRunFinalize`, `onHardCapTriggered`, `onBudgetWarning`, `onPlanningStartupFailure`, `onCoordinatorRuntimeFailure`, `sendWorkerMessageToSession`. -- `missionLaneId`, `provisionLane`. -- `userRules: CoordinatorUserRules` — provider preference, cost mode, max parallel workers, allow-parallel-agents, allow-sub-agents, allow-claude-agent-teams, lane strategy, coordinator model, closeout contract, budget limits, recovery enabled/max iterations. - -### Lifecycle states - -`CoordinatorLifecycleState`: - -- `booting` — initializing. -- `analyzing_prompt` — parsing the mission goal. -- `fetching_project_context` — loading project state. -- `launching_planner` — spawning the planning worker. -- `waiting_on_planner` — planner is running. -- `planner_launch_failed` — planner spawn failed; triggers retry or intervention. -- `stopped` — coordinator has shut down. - -### Constants - -- `BATCH_DELAY_MS = 200` — coalesces incoming events before processing. -- `MAX_TOOL_STEPS_PER_TURN = 25` — upper bound on tool calls per coordinator turn. -- `COMPACTION_THRESHOLD_RATIO = 0.50` — when to trigger context compaction. -- `MAX_CONVERSATION_HISTORY = 200` — max turns held in the coordinator conversation before compaction. -- `MAX_EVENT_RETRY_COUNT = 2` — retries per queued event. -- `CHECKPOINT_TURN_INTERVAL = 5` — checkpoint every N turns. - -### Compaction - -Uses `createCompactionMonitor` from `ai/compactionEngine`. When the conversation exceeds the compaction threshold, `compactConversation` builds a summarized transcript and resumes. The coordinator identity and phase prompt are re-injected after compaction (same pattern as CTO identity re-injection). - -### Checkpointing - -Every `CHECKPOINT_TURN_INTERVAL` turns, the coordinator writes a checkpoint via `writeCoordinatorCheckpoint`. On resume, `readCoordinatorCheckpoint` restores conversation state. Checkpoint path is resolved via `getCoordinatorCheckpointPath`. - -## Coordinator tools - -`createCoordinatorToolSet` registers tools on the coordinator agent. Each tool has a schema (Zod), a handler, and a `CoordinatorExecutableTool` wrapper for permission checks (`checkCoordinatorToolPermission`). Highlights: - -- `spawn_worker` — launches a worker attempt. Respects `allowParallelAgents` and the mission's max-parallel-workers cap. Honors role isolation rules (`DEFAULT_ROLE_ISOLATION_RULES`). When the spawn request omits an explicit phase, `inferWorkerPhaseHintFromRequest` reads the worker name + role + prompt and matches it to one of `planning`/`implementation`/`testing`/`validation`/`integration` so phase routing degrades gracefully when the coordinator forgets to set one. Phase-order violations return a structured `blockedByPhaseOrdering` response with the phase that must advance first. Planning workers are stamped with `phaseAskQuestions.requiredBeforeExit` when either the phase card or mission/prompt text requires a blocking question before implementation. App-restart orphans are detected via `isAppRestartOrphanAttempt` (matches against the canonical orphan / shutdown / lost-session error messages) so they are surfaced for retry instead of being treated as real failures. -- `send_message_to_worker` — routes a message into an active worker chat via `sendWorkerMessageToSession`. -- `check_status` — reads run/step/attempt state. -- `ask_user` — creates a `manual_input` intervention. -- `mark_phase_complete` — advances the phase cursor. It blocks leaving Planning when required clarification is still unresolved, while keeping phase-approval interventions as a separate gate. -- `request_human_review` — creates an `awaiting_human_review` intervention. -- `request_lane` — asks the orchestrator to provision a new lane via `provisionLane`. -- `retry_step` — resets a failed step to pending. For app-restart orphan attempts it can extend that step's retry limit once and marks the metadata with `infraRetryLimitExtended`. -- `fail_mission` — appends the failure runtime event, marks the run failed, and best-effort synchronizes the mission status / outcome summary so the dashboard does not remain active after coordinator failure. -- `finalize_run` — synthesizes the result lane and ends the run. -- `check_finalization_status` — reads `state.md` and returns contract-satisfaction, execution-completeness, and result-lane readiness. -- DAG mutation helpers — `add_step`, `add_dependency`, `remove_step`, `update_step_metadata`. - -## Run graph - -`OrchestratorRunGraph` contains: - -- `run` (`OrchestratorRun`). -- `steps` (`OrchestratorStep[]`) with status, dependencies, fan-out variants. -- `attempts` (`OrchestratorAttempt[]`) per step. -- `claims` (`OrchestratorClaim[]`) — file-path and path-scope claims to prevent overlapping workers. -- `artifacts` (`OrchestratorArtifact[]`). -- `timelineEvents`. -- `runtimeEvents`. -- `gateReports` (validation gate outputs). -- `reflections` (`OrchestratorReflectionEntry[]`). - -## Execution policy - -`MissionExecutionPolicy` is the per-phase executor configuration: - -- `planning.mode: "auto" | "off"`, `planning.model`. -- `implementation.model`. -- `testing.mode: "post_implementation" | "off"`, `testing.model`. -- `validation.mode: "optional" | "required" | "off"`, `validation.model`. -- `codeReview.mode`, `testReview.mode`, `prReview.mode`, `merge.mode` (all off by default). -- `prStrategy.kind` — default `"manual"`, but the closeout contract enforces `"result_lane"` finalization regardless. - -`resolveExecutionPolicy({ missionMetadata, projectConfig, fallback })` merges by priority: mission metadata > project config > fallback > `DEFAULT_EXECUTION_POLICY`. - -## Adaptive runtime - -`classifyTaskComplexity(description)` returns one of `trivial` / `simple` / `moderate` / `complex` based on keyword heuristics plus word count and filename-reference count. This drives: - -- parallelism caps — trivial/simple runs fewer parallel workers. -- model downgrade — simple tasks can switch to a smaller model to save budget. -- budget allocation — complex tasks pre-allocate a larger budget buffer. - -## Delegation contracts - -Every worker spawn creates a `DelegationContract`: - -- Scope — what the worker is allowed to touch (file paths, lane, step ids). -- Allowed tools — enforced per provider via `permissionMapping.ts`. -- Handoff shape — what the worker must produce on completion. -- Failure policy — retry budget, fallback path. - -`extractDelegationContract` / `updateDelegationContract` / `derivePlanningStartupStateFromContract` keep the contract in sync during runtime. `extractActiveDelegationContracts` surfaces the currently active contracts for the coordinator's "what's running" view. - -## Planning artifact persistence and intervention recovery - -`workerTracking.ts` owns the post-attempt artifact pass for every -worker completion. `extractAndRegisterArtifacts(ctx, { graph, attempt })` -walks the attempt's `resultEnvelope`, writes the canonical plan -markdown under `.ade/missions//plan.md`, registers it via -`registerArtifact`, and returns `{ planArtifactPersisted }`. The flag -is `true` only when the plan markdown was actually written **and** -the artifact row was registered in this attempt — `report_result.plan.markdown` -alone is insufficient, because the underlying `fs.writeFileSync` or -`registerArtifact` may have failed silently. - -A `planner_plan_missing` intervention (`interventionType: "failed_step"`, -`reasonCode: "planner_plan_missing"`) is opened when the planner -completes without a usable plan. `resolvePlannerPlanMissingInterventionsAfterPlanningSuccess` -is the matching auto-resolver: on any successful planning-phase -attempt (`stepType === "planning" | "analysis"` or -`phaseKey === "planning"`), if and only if `planArtifactPersisted` is -`true`, it resolves every open `planner_plan_missing` intervention on -the mission and emits a runtime event -(`eventType: "intervention_resolved"`, -`eventKey: "intervention_resolved::planner_plan_recovered"`). - -The resolver is intentionally cross-run: any later successful -planning attempt can clear a stale intervention that was recorded by -a previous run. Without the `planArtifactPersisted` gate the resolver -could clear the intervention when the plan was never actually -written, so the persistence check is load-bearing — do not relax it -to just checking `report_result.plan`. - -After step/phase sync, `syncMissionFromRun()` re-reads mission detail -before deriving any non-terminal mission status. Sync can add or -resolve interventions, so deriving status from the stale pre-sync -mission snapshot can overwrite `intervention_required` back to -`in_progress`. Only an explicit `nextMissionStatus` skips that -re-derive. - -`ensureTerminalFailedRunIntervention()` runs right after -`syncMissionStepsFromRun()` for terminal-`failed` runs. When no open -`failed_step` row exists it adds one (reason -`terminal_run_failed_step`) and flips the mission to -`intervention_required` even though the run is terminal. The change in -`deriveMissionStatusFromRun()` reflects the same priority: blocking -interventions are checked **before** `run.status === "failed"`, so a -terminal run with an open intervention surfaces as -`intervention_required` rather than `failed`. The `else if -(nextMissionStatus === "failed")` branch in `syncMissionFromRun()` -re-derives once more against the latest mission snapshot before -committing the transition, so newly-opened interventions still win. - -## Runtime event routing - -`runtimeEventRouter.routeEventToCoordinator()` classifies an incoming event (worker output, CLI signal, test result, gate report) and decides whether to: - -- append it to the coordinator thread verbatim, -- create an intervention, -- trigger a coordinator evaluation, -- or update run / step / attempt status directly. - -This is the critical path for dedup: both this router and `orchestratorService.completeAttempt` can create interventions, so both code paths must check for an existing-open intervention before inserting (see `VAL-INTV-001`). - -## Worker delivery - -`workerDeliveryService` handles delivering a coordinator message to an active worker chat. It uses: - -- Retry with backoff (`computeWorkerRetryBackoffMs`). -- In-flight leases (`WORKER_MESSAGE_INFLIGHT_LEASE_MS`) to prevent double delivery. -- Stale-fail threshold (`WORKER_MESSAGE_INFLIGHT_STALE_FAIL_MS`) to unstick orphaned leases. -- Queued replay (`replayQueuedWorkerMessages`) on worker reconnect. -- Error normalization (`normalizeDeliveryError`, `isBusyDeliveryError`, `isNoActiveTurnError`). - -## Recovery loop - -`RecoveryLoopPolicy` (default in `orchestratorConstants.ts`) controls: - -- Whether recovery is enabled. -- Max iterations. -- Per-iteration budget. -- Whether to switch provider/model on retry. - -`recoveryService.getTrackedSessionState()` tracks per-session recovery progress. `RecoveryLoopState` is persisted on the run so recovery survives restarts. - -## Prompt inspectors - -`promptInspector.ts` exposes three builders: - -- `buildCoordinatorPromptInspector(args)` — what the coordinator sees. -- `buildPlanningPromptPreview(args)` — what the planner sees. -- `buildWorkerPromptInspector(args)` — what a worker sees (per attempt). - -The mission UI renders these for debugging and transparency. - -## Gotchas - -- **`missionLifecycle.ts` is not self-contained.** It declares types and re-exports signatures but the actual closures live in `aiOrchestratorService.ts`. Follow the `deps` parameter pattern when adding functionality. -- **Compaction must re-inject identity.** If you skip re-injection after compaction, the coordinator drifts into generic-chatbot behavior mid-mission. -- **DAG mutations are event-driven.** The coordinator emits `DagMutationEvent`s; the orchestratorService applies them. Don't mutate run-graph rows directly from coordinator code. -- **Role isolation rules block cross-role writes.** `DEFAULT_ROLE_ISOLATION_RULES` prevents, e.g., the QA worker from editing implementation files. Adjust with care; `roleIsolationValidation` tests cover the invariants. -- **File claims overlap detection is recursive.** `doFileClaimsOverlap` and `doesFileClaimMatchPath` use `normalizeRepoRelativePath`; any path normalization change must round-trip through the test suite. -- **`tickRun` must remain idempotent.** It is the main liveness loop; changes that introduce side effects per tick will compound over time. - -## Cross-links - -- `README.md` — mission overview and runtime contract. -- `validation-gates.md` — VAL-XXX assertions grouped by area. -- `workers.md` — worker pool, concurrency, role isolation. -- `../cto/README.md` — the CTO can launch missions via `startMission` operator tool; CTO tool surface is separate from the coordinator tool surface. -- `../computer-use/README.md` — mission preflight and run monitoring read from the computer-use broker. diff --git a/docs/features/missions/validation-gates.md b/docs/features/missions/validation-gates.md deleted file mode 100644 index 298d7cf7a..000000000 --- a/docs/features/missions/validation-gates.md +++ /dev/null @@ -1,115 +0,0 @@ -# Validation Gates (Behavioral Contract) - -Missions have a dedicated validation contract that lives outside the normal unit-test suite: `docs/validation-contract-m1-m2.md`. It is a set of behavioral assertions — each with a title, behavioral description, pass/fail conditions, and explicit evidence requirements. Assertions are ID'd as `VAL--`. - -This document indexes the assertions by area and notes where the backing tests live. It is a pointer to the contract, not a replacement for it. - -The orchestrator that enforces these invariants runs inside the runtime daemon (local `ade serve` or remote runtime) — every VAL-XXX assertion is a runtime-side guarantee. - -## Source file map - -- `docs/validation-contract-m1-m2.md` — the contract (366 lines, 19 assertions). -- `apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts` — VAL-ISO-*. -- `apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts` — VAL-STATE-*. -- `apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts` — VAL-INTV-*, VAL-STEER-*, VAL-ERR-*. -- `apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts`, `planningGapsFixes.test.ts` — VAL-PLAN-*, VAL-HAND-*, VAL-ART-*. -- `apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts` — hardening regressions. -- `apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts` — coordinator tool behavior including VAL-ISO-002. -- `apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts` — end-to-end runtime integration. -- `apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts` — row-level orchestratorService assertions, including VAL-BUDGET-002 coverage. - -## Assertion index - -### Area: Worktree Isolation - -Ensures workers run inside lane worktrees and never write to the primary repo without an explicit constraint. - -- **VAL-ISO-001** — Worker CWD resolves to `lanes.worktree_path`, not `projectRoot`. Null/empty worktree MUST fail with `errorClass: "configuration_error"` — no silent fallback. Evidence in `worktreeIsolation.test.ts` (mocked lane with `worktree_path = "/tmp/test-worktree"` and null variant). Code reference: `orchestratorService.ts` ~lines 6556–6563. -- **VAL-ISO-002** — `buildFullPrompt()` must include `"You are working in: "` and `"All file edits MUST be made in the worktree path above."` when a lane worktree is present. Tested in `coordinatorTools.test.ts` (~lines 1664–1666). - -### Area: State Coherence - -Ensures mission and run statuses stay mutually consistent. - -- **VAL-STATE-001** — Mission `intervention_required` is never set while a run is `active` or `bootstrapping`. `transitionMissionStatus(..., "intervention_required")` must pause all runs first. Evidence in `stateCoherence.test.ts`; code reference: `pauseRunWithIntervention` in `aiOrchestratorService.ts` ~5686 must call `orchestratorService.pauseRun()` before `missionService.addIntervention({ pauseMission: true })`. -- **VAL-STATE-002** — A parent step that fanned out to variants MUST transition to `succeeded` (at least one variant succeeded) or `failed` (all failed) after variants terminal-ize — never remain `ready` or `running`. Code reference: `completeAttempt()` checks remaining sibling attempts and transitions the parent step on last-variant completion. - -### Area: Intervention Deduplication - -Prevents duplicate interventions for single failure events. - -- **VAL-INTV-001** — At most one open `failed_step` intervention per step. N distinct step failures produce N interventions; re-failing the same step with an open intervention produces no new intervention. Code reference: intervention creation in `missionService.ts` ~3335 and `aiOrchestratorService.ts` must both check for an existing-open intervention before inserting. -- **VAL-INTV-002** — Intervention creation is idempotent across the dual creation paths (`completeAttempt` + `runtimeEventRouter.routeEventToCoordinator`). Concurrent failure handlers must not race to create duplicates. - -### Area: Steering & Resume - -Ensures steering a paused mission actually unsticks it. - -- **VAL-STEER-001** — `steerMission({ missionId, directive: "continue" })` on a paused mission with open `manual_input` interventions must: - 1. resolve matching interventions, - 2. resume the paused run to `active`, - 3. transition the mission to `in_progress`. - Code reference: `steerMission()` (~`aiOrchestratorService.ts` line 7400+) must call `orchestratorService.resumeRun()` after resolving interventions; `triggerCoordinatorEvaluation()` must fire so the coordinator picks up the state. - -### Area: Worker Error Classification - -Distinguishes "never started" from "started but interrupted". - -- **VAL-ERR-001** — `classifySilentWorkerExit()` (`orchestratorService.ts` ~547–572) must return `interrupted` or `executor_failure` for workers that produced tool activity before termination. `startup_failure` is reserved for workers that never produced any assistant or tool activity (`hasMaterialOutput === false` and no transcript summary and no tool activity). - -### Area: Budget Pause Consistency - -Two budget pause paths must converge on the same downstream effects. - -- **VAL-BUDGET-001** — Token budget exceeded in `completeAttempt()` (`orchestratorService.ts` ~6301–6317 and ~8022–8031) and hard cap in `coordinatorTools.ts` (~4806, ~5106) must both produce: - - run paused, - - `budget_limit_reached` intervention, - - `mission_paused` timeline event, - - mission status `intervention_required`. - Both paths should converge on `pauseMissionWithIntervention()` or an equivalent helper. -- **VAL-BUDGET-002** — `tickRun()` (`orchestratorService.ts` ~5816) must never auto-resume a budget-paused run. Paused runs stay paused until explicit user action (steer or resume). Evidence: paused run remains paused after 10 calls to `tickRun()`. Partial coverage already in `orchestratorService.test.ts:3659`. - -### Area: Planning Artifacts - -Ensures planning outputs land in project-local `.ade/plans/` and not in home-directory sandboxes. - -- **VAL-PLAN-001** — Planner worker writes plan files to `/.ade/plans/`, never to `~/.claude/plans/`. Sandbox policy should block home-dir writes and prompts must reference project-local paths. Coordinator prompt already warns against `ExitPlanMode` (`coordinatorAgent.ts` ~1070); the planner prompt must also redirect plan writes. -- **VAL-PLAN-002** — `ExitPlanMode` MUST NOT be triggered during mission planning. The coordinator prompt blocks the tool; the planner prompt reinforces it; the runtime event router should classify an `ExitPlanMode` invocation as a planning-guard violation. -- **VAL-PLAN-003** — When the planner needs clarification it must call `ask_user` (which creates a `manual_input` intervention), not emit free-text questions the user has to scrape out of the transcript. - -### Area: Worker Handoff Data - -Ensures workers produce structured handoff data on completion. - -- **VAL-HAND-001** — Worker completion must produce a `DelegationContract` handoff object with the contract's required shape. Missing or malformed handoff data should mark the attempt failed with `errorClass: "handoff_malformed"`, not silently accept partial output. -- **VAL-HAND-002** — Downstream workers must receive upstream handoff summaries in their prompt. `buildFullPrompt()` injects the relevant handoff context from the completed upstream attempts. - -### Area: Artifact Visibility - -Ensures planning + worker artifacts are discoverable as mission artifacts. - -- **VAL-ART-001** — Planning artifacts (plan files under `.ade/plans/`) are registered as `MissionArtifact` records so they appear in the Artifacts tab and survive a mission list query. -- **VAL-ART-002** — `OrchestratorWorkerCheckpoint` entries are accessible as mission artifacts alongside any produced files. Mission UI treats checkpoints as first-class artifacts, not hidden orchestrator state. - -## How to work with the contract - -When changing orchestrator code: - -1. Identify which area(s) the change intersects. -2. Read the existing assertion bodies and their code references in `docs/validation-contract-m1-m2.md`. The code references are approximate line numbers — grep by function name. -3. Run the corresponding test file. Each area maps to one or two primary test files listed in the source file map. -4. If adding a new invariant, extend the contract and add a VAL-XXX entry with a pass/fail condition and explicit evidence requirements. The contract is the source of truth for what "correct runtime behavior" means — PR descriptions should reference the VAL-XXX id. - -## Gotchas - -- **The contract is not auto-verified.** There is no single test runner that fails if an assertion is broken — each assertion maps to a specific behavioral test. Adding a VAL id without wiring a test leaves a silent gap. -- **Line-number references drift.** The contract lists approximate line numbers inside `orchestratorService.ts` and related files. After large refactors, code references will be stale but the behavioral description is still the source of truth. -- **Dual-path invariants need both tests.** VAL-INTV-002 and VAL-BUDGET-001 specifically guard against one path diverging from the other; adding coverage only to one path defeats the assertion's intent. -- **`transitionMissionStatus` guard skips transitions silently.** Any future logging of these skips should be careful not to regress the silence-when-terminal behavior described in the README. - -## Cross-links - -- `README.md` — mission overview and runtime contract. -- `orchestration.md` — detailed orchestrator component map. -- `workers.md` — worker pool, role isolation, concurrency (relates to VAL-HAND-*, VAL-ART-*). -- `docs/validation-contract-m1-m2.md` — the canonical contract. diff --git a/docs/features/missions/workers.md b/docs/features/missions/workers.md deleted file mode 100644 index be2b551d1..000000000 --- a/docs/features/missions/workers.md +++ /dev/null @@ -1,186 +0,0 @@ -# Mission Workers - -Mission workers are transient, role-scoped agents spawned by the coordinator to execute specific phases of a mission. They are distinct from CTO "Team" workers (see `../cto/workers.md`) — Team workers are stable identities the operator configures; mission workers are ephemeral, spawned by `spawn_worker` with a role and a delegation contract that applies only to the current run. - -Workers always launch on the runtime that owns the mission. Provider availability follows the runtime host: a remote runtime that lacks the Claude CLI cannot spawn `claude-local` workers, and macOS-only capabilities (iOS Simulator, native screencapture) are only reachable when the runtime itself is on macOS. - -## Source file map - -- `apps/desktop/src/main/services/orchestrator/coordinatorTools.ts` — `spawn_worker` tool, `classifyPlannerLaunchFailure`. -- `apps/desktop/src/main/services/orchestrator/delegationContracts.ts` — `DelegationContract` type, scope helpers, handoff shape. -- `apps/desktop/src/main/services/orchestrator/orchestratorService.ts` — attempt lifecycle (`startAttempt`, `completeAttempt`, `failAttempt`, `cancelAttempt`), claims, fan-out parent/variant tracking. -- `apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts` — message delivery between coordinator and worker, retry, in-flight leases. -- `apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts` — `buildFullPrompt`, shell escaping. -- `apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts` — provider-specific launchers (Claude CLI, Codex CLI, ADE CLI). -- `apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts` — step policy, autopilot config, file-claim scope. -- `apps/desktop/src/main/services/orchestrator/teamRuntimeConfig.ts` / `teamRuntimeState.ts` — team manifest, per-run worker roster. -- `apps/desktop/src/main/services/orchestrator/permissionMapping.ts` — mission permission config -> provider-specific allowed tools. -- `apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts` — `DEFAULT_ROLE_ISOLATION_RULES`, `DEFAULT_RECOVERY_LOOP_POLICY`, `DEFAULT_CONTEXT_VIEW_POLICIES`. -- `apps/desktop/src/main/services/orchestrator/workerTracking.ts` — tracked session state, heartbeats. -- `apps/desktop/src/main/services/orchestrator/recoveryService.ts` — recovery iteration policy. - -## Worker roles - -`OrchestratorWorkerRole` values: - -- `planner` — runs during the planning phase; produces the execution DAG and plan artifacts. -- `implementation` — writes code inside the assigned lane worktree. -- `testing` — runs tests, captures results, produces test artifacts. -- `validation` — validates completion against the execution policy and gate reports. -- `code_review` — reviews implementation diffs (read-only). -- `test_review` — reviews test results and test diffs (read-only). -- `pr_review` — reviews PR readiness (read-only). -- `merge` — executes merges (controlled by `merge.mode`). - -Role isolation rules (`DEFAULT_ROLE_ISOLATION_RULES`) enforce mutual exclusivity: - -- `implementation` and `code_review` cannot share a worker (reviewers must not implement code they reviewed, and vice versa). -- `implementation` and `test_review` cannot share. -- `testing` and `test_review` cannot share (test authors must not review their own results). - -`enforcement: "auto_correct"` means the runtime will refuse to assign both roles to the same worker and will route the second role to a distinct worker. - -## Delegation contract - -Each `spawn_worker` creates a `DelegationContract`: - -- `scope` — what the worker can touch: file-path globs, lane id, step ids, read-only flag. -- `allowedTools` — provider-specific tool list, derived via `permissionMapping.ts` from `MissionProviderPermissions`. -- `handoff` — expected output shape: summary, artifacts, next-step hints, human-review requests. -- `failurePolicy` — retry budget, fallback model, escalation (auto-retry vs intervention). -- `role` — one of the roles above. - -Contract helpers in `delegationContracts.ts`: - -- `createDelegationContract` — produces the initial contract from a `spawn_worker` invocation. -- `createDelegationScope` — narrows the scope. -- `extractDelegationContract(metadata)` — reads a contract off a persisted attempt. -- `updateDelegationContract(contract, patch)` — merges a partial update. -- `extractActiveDelegationContracts(run)` — returns currently active contracts for the coordinator's "what's running" view. -- `derivePlanningStartupStateFromContract` — maps a planner contract into the coordinator's planning-startup state machine. -- `normalizeCoordinatorToolName` — canonicalizes tool names across providers (`foo` vs `foo`). - -## Attempt lifecycle - -1. **Claim** — `orchestratorService.acquireClaim()` grabs file and path scopes. Overlapping claims refuse (`doFileClaimsOverlap`, `doesFileClaimMatchPath`). -2. **Start** — `startAttempt` writes the attempt row, starts tracking (`workerTracking.ts`), and launches the executor. -3. **Execute** — provider-specific launcher runs the worker (Claude CLI / Codex CLI / ADE CLI). Messages flow through `workerDeliveryService`. -4. **Complete** — `completeAttempt(attemptId, result)` applies the handoff, releases claims, updates step status, and potentially transitions the parent step (for fan-out steps). -5. **Fail** — `failAttempt` classifies the error via `classifySilentWorkerExit` / `classifyPlannerLaunchFailure`, creates an intervention if needed (with dedup), and may trigger recovery. -6. **Cancel** — `cancelAttempt` cancels the tracked session and marks the attempt as cancelled without failing the step if other variants remain. - -Attempt status lifecycle: - -``` -queued -> running -> (succeeded | failed | cancelled) -``` - -`OrchestratorAttemptResultEnvelope` is the typed shape of the result payload. - -## Fan-out - -A step can spawn multiple parallel `variant` attempts. The parent step stays `running` until all variants reach a terminal state. Then: - -- At least one `succeeded` -> parent `succeeded`. -- All `failed` -> parent `failed`. -- Mixed fail/cancel with no success -> parent `failed`. - -See `VAL-STATE-002` in the validation contract. - -Variant attempt metadata carries a `variantGroupId` so `completeAttempt` can efficiently query sibling status. Fan-out is a core pattern for roles like `code_review` and `test_review` which can run N reviewers in parallel. - -## Concurrency caps - -The coordinator respects: - -- `userRules.maxParallelWorkers` — per-mission cap. -- `userRules.allowParallelAgents` — kill switch for any parallelism. -- `userRules.allowSubAgents` — whether a worker can itself spawn sub-workers. -- `userRules.allowClaudeAgentTeams` — whether to use Claude's built-in sub-agent teams. - -`adaptiveRuntime.classifyTaskComplexity` also scales parallelism based on mission complexity: `trivial` / `simple` run serially, `moderate` / `complex` parallelize. - -## Provider launch paths - -`providerOrchestratorAdapter.ts` supports: - -- **Claude CLI** — `resolveClaudeCliModel`, spawns the Claude Code CLI binary with `--model`, `--append-system-prompt`, and a tailored tool allowlist. -- **Codex CLI** — `resolveCodexCliModel`, spawns the Codex CLI with a similar config. -- **ADE CLI** — workers inherit ADE context env vars and can call the `ade` command for ADE operator actions against the same runtime daemon they were spawned by. - -Each launcher reads the `classifyWorkerExecutionPath(model)` classification from the model registry to decide between provider CLI and managed in-runtime execution. CLI binaries are resolved on the runtime host's `PATH`; missing binaries surface as `startup_failure` rather than a silent fallback. - -## File-claim scope - -Claims are the concurrency-safety mechanism: - -- `normalizeRepoRelativePath` — resolves input paths relative to the repo root. -- `normalizeFileClaimScopeValue` — canonicalizes claim scope strings. -- `doesFileClaimMatchPath(claim, path)` — tests whether a claim covers a given path. -- `doFileClaimsOverlap(a, b)` — tests whether two claims overlap. - -Overlapping claims are rejected at acquisition time — the second attempt to claim an overlapping scope receives a scoped failure and must either wait or fail. This is how the runtime prevents two workers from simultaneously editing overlapping files. - -## Sandboxing and permissions - -`permissionMapping.ts` maps `MissionProviderPermissions` (the mission's permission config) into provider-specific allowed-tool lists: - -- Claude CLI — allowed-tools list plus working-dir constraints. -- Codex CLI — allowed tools plus sandbox mode. -- ADE CLI — the in-process permission structure (`mapPermissionToInProcess`) used when workers call ADE tools over ADE CLI. - -`WorkerSandboxConfig` (in `shared/types`) controls sandbox mode, allowed network hosts, allowed file roots. Default is strict worktree-only writes. - -## Worker tracking - -`workerTracking.ts` keeps per-session state: - -- session id, attempt id, tracked pid, started-at, last-event-at. -- active status: `active` / `stagnant` / `terminated`. -- stagnation detection: `WORKER_EVENT_HEARTBEAT_INTERVAL_MS` defines the expected activity cadence. - -Stagnant workers surface to the coordinator so it can intervene (usually by sending a nudge via `send_message_to_worker` or by cancelling). - -## Recovery - -`RecoveryLoopPolicy` (default): - -- `enabled: true` -- `maxIterations: 3` -- `onExhaustion: "intervention"` — escalate when retries are exhausted. -- `minConfidenceDelta: 0.1` — minimum progress between iterations to avoid stagnation. -- `escalateAfterStagnant: 2` — escalate after N stagnant iterations. - -`recoveryService.getTrackedSessionState` reads per-session recovery progress so the coordinator can decide whether to retry, switch provider/model, or escalate to the user. - -## Context views - -`DEFAULT_CONTEXT_VIEW_POLICIES` defines what each role sees: - -- `implementation` — full diff, scratch context, artifacts, handoff summaries, check results. Not read-only. -- `review` — artifacts + handoff summaries + full diff. Read-only. -- `test_review` — artifacts + handoff summaries + summary diff. Read-only. - -Per-role context isolation keeps reviewers honest (they don't get to read the implementer's scratch notes). - -## Handoff - -On completion, a worker must produce a structured handoff (VAL-HAND-001). The coordinator injects relevant handoff summaries into downstream workers' prompts (VAL-HAND-002) via `buildFullPrompt`. - -The handoff shape lives in `DelegationContract.handoff`. If the worker returns malformed handoff data, `completeAttempt` marks the attempt failed with `errorClass: "handoff_malformed"` instead of silently accepting partial output. - -## Gotchas - -- **Mission workers are not Team workers.** Don't conflate `OrchestratorWorkerRole` (phase-bound) with `AgentRole` (team identity). -- **Role isolation is auto-corrective.** The runtime will refuse to assign both roles to the same worker. Don't code around it by packing roles into one worker — split the attempts. -- **Fan-out parent transitions are delicate.** `completeAttempt` must query sibling attempts and transition the parent in one atomic step; races produce `VAL-STATE-002` regressions. -- **Claim scope normalization is the concurrency safety boundary.** Any path-normalization change needs to round-trip through `doFileClaimsOverlap` and the existing tests. -- **Sandbox escape via `WorkerSandboxConfig` is provider-specific.** Adding a new allowed-root requires testing against both Claude and Codex sandbox modes. -- **Handoff validation belongs at `completeAttempt`**, not at the coordinator level. Moving the check into the coordinator produces inconsistent behavior when workers crash without sending a completion message. - -## Cross-links - -- `README.md` — mission overview. -- `orchestration.md` — coordinator, delegation contracts, DAG model. -- `validation-gates.md` — VAL-ISO-*, VAL-STATE-*, VAL-HAND-* assertions that gate worker behavior. -- `../cto/workers.md` — the Team worker identity model (stable, configurable) that is different from mission workers. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 6b65b573b..7f0f3a0e8 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -134,7 +134,7 @@ Renderer — onboarding: dialog-scoped steps can be composed from multiple tours. - `apps/desktop/src/renderer/onboarding/tours/*.ts` — per-surface tours: `lanesTour`, `laneWorkPaneTour`, `workTour`, `filesTour`, - `runTour`, `missionsTour`, `prsTour`, `graphTour`, `historyTour`, + `runTour`, `prsTour`, `graphTour`, `historyTour`, `automationsTour`, `ctoTour`, `settingsTour`, plus the first-session `firstJourneyTour`. The first-session tour reuses individual steps from the per-surface tours via a small `tutorialSection(sectionId, @@ -416,8 +416,8 @@ behavior. Current behavior: - **guest mode** — deterministic features remain usable; AI-backed features degrade cleanly (no narrative generation, no summaries). -- **subscription mode** — unlocks chat, narratives, summaries, - mission orchestration. +- **subscription mode** — unlocks chat, narratives, summaries, and + worker delegation. Legacy `providers.mode` migration ran during earlier releases and is no longer part of the contract; `projectConfigService` still contains diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 6152a9baf..351cd9e1a 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -325,7 +325,7 @@ no longer written back — once a project is loaded, writes go to the Full schema lives in `AutomationRule` (see `config.ts` around line 749). Key slots: `trigger`, `actions`, `execution` (agent session / -mission / built-in), `executor`, `contextSources`, +built-in), `executor`, `contextSources`, `guardrails`, `outputs`, `verification`. Triggers cover session end, git events, file changes, lane lifecycle, Linear webhooks, GitHub webhooks, and schedules. diff --git a/docs/features/proof.md b/docs/features/proof.md index 274a7cdc0..b1b1dc4a0 100644 --- a/docs/features/proof.md +++ b/docs/features/proof.md @@ -25,7 +25,7 @@ Three subcommands under `ade proof`. Each prints a JSON summary on success and e Take a screenshot now and file it as proof for the current session. ``` -ade proof capture [--caption ""] [--owner-kind chat|mission|lane] [--owner-id ] +ade proof capture [--caption ""] [--owner-kind chat|lane] [--owner-id ] ``` - `--caption` — short free-text label. Prominent in the drawer grid. @@ -70,7 +70,7 @@ The file is copied into `.ade/artifacts/computer-use/`; the original is left in Print the proof set for the current session as JSON. ``` -ade proof list [--owner-kind chat|mission|lane] [--owner-id ] [--limit ] +ade proof list [--owner-kind chat|lane] [--owner-id ] [--limit ] ``` No args: lists the inferred session. Primarily for agents to see what they have already captured. @@ -84,10 +84,9 @@ The CLI resolves the owner of a capture from environment variables set by the de | Env var | Owner kind | Precedence | |---|---|---| | `ADE_CHAT_SESSION_ID` | `chat` | highest | -| `ADE_MISSION_ID` | `mission` | middle | | `ADE_LANE_ID` | `lane` | lowest | -Agents spawned inside ADE pick up the right owner automatically. If more than one var is set — e.g. a mission worker also has a lane — the highest-precedence kind wins. +Agents spawned inside ADE pick up the right owner automatically. If more than one var is set — e.g. a chat also has a lane — the highest-precedence kind wins. If no env var is set and no `--owner-kind`/`--owner-id` flags are passed, `ade proof capture` exits with code `3`. This is deliberate: an un-owned proof has no home in the UI. @@ -95,7 +94,7 @@ If no env var is set and no `--owner-kind`/`--owner-id` flags are passed, `ade p The `screenshot_environment`, `record_environment`, `ingest_computer_use_artifacts`, `get_environment_info`, `interact_gui`, and `list_computer_use_artifacts` JSON-RPC tools accept explicit `ownerKind` + `ownerId` fields. `resolveComputerUseOwners` in `apps/ade-cli/src/adeRpcServer.ts` is the single normalizer: -- Canonical kinds: `lane`, `mission`, `orchestrator_run`, `orchestrator_step`, `orchestrator_attempt`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. +- Canonical kinds: `lane`, `chat_session`, `automation_run`, `github_pr`, `linear_issue`. - Friendly aliases: `chat` → `chat_session`, `pr` → `github_pr`. Any other value raises a `JsonRpcError(invalidParams)` with an "Unsupported proof ownerKind" message. Explicit owners are added in addition to the session identity inferred from `ADE_*` env vars, so an agent can attach the same artifact to its current chat plus a specific PR in one call. @@ -123,7 +122,7 @@ There is no retention policy — captures persist until the project is cleaned u Proof surfaces in two places: - **Chat** — the proof drawer below the composer shows a thumbnail grid for the current session. Captions are rendered in full below each thumbnail, not as hover tooltips. Click to preview at full size. -- **Mission detail** — the Proof tab shows the same grid for the mission, scoped by `ADE_MISSION_ID`. +- **Lane and PR review** — linked proof can be surfaced alongside lane work and PR closeout. Review controls (accept / reject / annotate) remain as first-class actions on each proof. diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 3e175e026..9c9f3c390 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -87,7 +87,7 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `prsRouteState.ts` | URL ↔ page state mapping plus project-scoped last-route storage. When a project root is known, the PRs tab reads only that project's stored route and does not fall back to the legacy global route from another project. | | `CreatePrModal.tsx` | Draft/queue/integration PR creation with lane warnings, branch name validation, and optional initial values for single-PR handoffs from lane/chat surfaces. A `target: "primary"` handoff resolves the base branch from the primary lane (falling back to `main`). | | `tabs/NormalTab.tsx` | Normal PR list | -| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. State filter is one of `open` / `closed` / `merged` / `all`. Mission result lanes are filtered to a dedicated section via `isMissionResultLane`. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. The "create lane from PR branch" affordance has been removed — open/closed PRs on branches without a lane no longer offer the preflight + create dialog (`prsPreflightCreateLaneFromPrBranch` / `prsCreateLaneFromPrBranch` IPC channels have been deleted), so creating a lane for an existing PR now goes through the standard lane creation flow. | +| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. State filter is one of `open` / `closed` / `merged` / `all`. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. The "create lane from PR branch" affordance has been removed — open/closed PRs on branches without a lane no longer offer the preflight + create dialog (`prsPreflightCreateLaneFromPrBranch` / `prsCreateLaneFromPrBranch` IPC channels have been deleted), so creating a lane for an existing PR now goes through the standard lane creation flow. | | `tabs/QueueTab.tsx` | Merge queue UI. Hosts the "Automate Merging" entry point that opens `QueueAutomateMergingModal` with the queue's eligible members (everything that has not landed yet). | | `tabs/QueueAutomateMergingModal.tsx` | Stack-wide automation modal: edits one `PipelineSettings` config that applies to every queue member, then sequentially saves settings, calls `ade.prs.retargetBase` for non-leading members so each PR's base points at the queue's tracking branch, starts Path-to-Merge via `ade.prs.pathToMerge.start`, and polls `convergenceStateGet` every 4 s until the runtime status is terminal. Halts the sequence on the first `failed | cancelled | stopped`. Closing mid-sequence stops dispatching new starts but leaves already-launched orchestrators running. | | `tabs/IntegrationTab.tsx` | Integration (merge-plan) proposals and execution, including merge-into-lane selection, apply-and-resimulate, and adopted-lane cleanup messaging | diff --git a/docs/features/pull-requests/queue.md b/docs/features/pull-requests/queue.md index e52709f1b..7b1a7695d 100644 --- a/docs/features/pull-requests/queue.md +++ b/docs/features/pull-requests/queue.md @@ -52,7 +52,6 @@ type QueueAutomationConfig = { permissionMode: ConflictResolverPermissionMode; confidenceThreshold: number | null; originSurface: ConflictResolverOriginSurface; - originMissionId: string | null; originRunId: string | null; originLabel: string | null; }; diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index 3601f9c00..36da40f0d 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -37,7 +37,7 @@ Runtime event streaming uses `ade/actions/call` with `name: "stream_events"` for Each `stream_events` response carries a per-runtime `eventEpoch` UUID minted when the daemon's `eventBuffer` is constructed. The preload event pump compares it against the last seen epoch for the active binding; if it changes (daemon restart, ssh reconnect to a fresh process) the cursor and dedup set reset and the next poll starts from `cursor=0`. The `startedAtMs` "drop events older than the pump start" filter is only applied to **local** bindings — remote pumps rely on the epoch reset instead, so older events backfilled after a reconnect are still delivered. -The remote event allowlist (`toRemoteRuntimeBufferedEvent`) accepts the runtime event categories desktop renders today: `category in {agent_chat, terminal, lane, pr, file_watch, process, test, project_state, orchestrator, dag_mutation, runtime, mission, pty}`. The runtime additionally emits source-tagged events that preload routes to dedicated remote subscribers: `usage`, `usage_threshold`, `automation_event`, `conflict_event`, `github_status_changed`, `linear_workflow_event`, `feedback_submission_event`, `computer_use_event`, `ios_simulator_event`, `app_control_event`, and `macos_vm` (re-keyed to its `eventType`). ade-cli wires these into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, App Control, and macOS VM events as the local host. +The remote event allowlist (`toRemoteRuntimeBufferedEvent`) accepts the runtime event categories desktop renders today: `category in {agent_chat, terminal, lane, pr, file_watch, process, test, project_state, orchestrator, dag_mutation, runtime, pty}`. The runtime additionally emits source-tagged events that preload routes to dedicated remote subscribers: `usage`, `usage_threshold`, `automation_event`, `conflict_event`, `github_status_changed`, `linear_workflow_event`, `feedback_submission_event`, `computer_use_event`, `ios_simulator_event`, `app_control_event`, and `macos_vm` (re-keyed to its `eventType`). ade-cli wires these into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, App Control, and macOS VM events as the local host. ## SSH transport diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index b416279cf..21270062d 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -74,7 +74,7 @@ directories. Two disconnected desktops do **not** have a shared live session. They converge code through Git and they converge the narrow tracked ADE -scaffold through Git, but live mission/chat/process state converges +scaffold through Git, but live chat/process state converges only when they join the same sync cluster (i.e. point at the same running daemon). @@ -259,7 +259,7 @@ Notification services (`apps/desktop/src/main/services/notifications/`): - `notificationMapper.ts` — pure domain-event → `MappedNotification` mapping across 13 categories in 4 families (chat, cto, pr, system). - `notificationEventBus.ts` — `publishChatEvent`, `publishPrEvent`, - `publishMissionEvent`, `publishSystemEvent`, `sendTestPush`. Routes + `publishSystemEvent`, `sendTestPush`. Routes to APNs (alert + Live Activity update pushes) and/or in-app WS delivery, filtered by per-device `NotificationPreferences`. @@ -349,9 +349,8 @@ never elect to host. Transfer: -1. Preflight blockers — active missions, running chat turns, live - PTYs, running managed processes. Paused missions, CTO history/idle - threads, and idle/ended chats are treated as durable synced state +1. Preflight blockers — running chat turns, live PTYs, running managed + processes. CTO history/idle threads and idle/ended chats are treated as durable synced state and survive a handoff. 2. Final sync flush on the old host. 3. `sync_cluster_state.brain_device_id` rewrites, `brain_epoch` @@ -505,8 +504,8 @@ payload. ## Command routing and execution isolation -Controllers never run agent processes. CTO heartbeats, worker -activations, and mission orchestration are host-exclusive +Controllers never run agent processes. CTO heartbeats and worker +activations are host-exclusive (host = the daemon). Two categories of controller write: diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index f6db3bbcb..467c25490 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -448,7 +448,6 @@ over Apple Push Notification service. The full stack is implemented: | `CHAT_COMPLETED` | chat | alert | 5 | | `CTO_SUBAGENT_STARTED` | cto | alert | 5 | | `CTO_SUBAGENT_FINISHED` | cto | alert | 5 | - | `CTO_MISSION_PHASE` | cto | alert | 5 | | `PR_CI_FAILING` | pr | alert | 10 | | `PR_REVIEW_REQUESTED` | pr | alert | 10 | | `PR_CHANGES_REQUESTED` | pr | alert | 10 | @@ -463,7 +462,7 @@ over Apple Push Notification service. The full stack is implemented: - `notificationEventBus.ts` — routes domain events to APNs and/or in-app WebSocket delivery. Call surface: `publishChatEvent`, - `publishPrEvent`, `publishMissionEvent`, `publishSystemEvent`, + `publishPrEvent`, `publishSystemEvent`, `sendTestPush`. The bus asks `listPushTargets()` and `getPrefsForDevice()` at send time so preference toggles take effect immediately. If the device is currently connected over WebSocket, @@ -504,7 +503,7 @@ over Apple Push Notification service. The full stack is implemented: `CHAT_AWAITING_INPUT` gets Approve + Deny + Reply (text input); `CHAT_FAILED` gets Open agent + Restart; `PR_CI_FAILING` gets Open PR + Retry checks; `PR_MERGE_READY` gets View PR; CTO - and system categories get generic Open / Open mission actions. + and system categories get generic Open actions. - `ADENotificationService/NotificationService.swift` — Notification Service Extension that decorates inbound APNs payloads before @@ -611,7 +610,7 @@ extension without importing the main app's heavier renderer code. ### Haptic Feedback - `UIImpactFeedbackGenerator` and `UINotificationFeedbackGenerator` - on message send, intervention approval, mission launch, PR merge. + on message send, intervention approval, worker launch, PR merge. ### Attention Drawer @@ -700,7 +699,7 @@ duplicate. Project list dedup runs as a final pass ### Planned -- Missions, Automations, Graph, History tabs. +- Automations, Graph, History tabs. - Full Settings parity with the desktop. - iPad adaptive layout, Spotlight. @@ -832,7 +831,6 @@ reflected in the phone's UI on the next descriptor read. | Work tab | Implemented; live chat-event push from host, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), message-to-continue on ended agent CLI rows | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | | Settings tab (pairing / appearance / diagnostics) | Implemented | -| Missions tab | Planned | | CTO / Automations / Graph / History tabs | Planned | | Full Settings parity | Planned | | Push notifications (APNs) | Implemented (categories, actions, Notification Service Extension, per-device preferences) | @@ -845,8 +843,8 @@ reflected in the phone's UI on the next descriptor read. - **Phones never host.** Any future feature that needs to run on the phone should be implemented as a controller operation that sends a - command to the host. Agent processes, PTYs, worktrees, workers, - mission orchestration — all host-side. + command to the host. Agent processes, PTYs, worktrees, and workers + are all host-side. - **The phone's local DB is authoritative for reads.** If a read looks stale, the fix is on the host push side (make sure the table is a CRR, make sure writes land in a table the phone reads), not diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 2afc767bc..a515d06d1 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -445,7 +445,7 @@ iOS Work surfaces: `PaneTilingLayout`-backed grid mode), `WorkStartSurface`, `TerminalView`, and state hooks. - [runtime-isolation.md](./runtime-isolation.md) — how a session stays - bound to a single lane worktree and a single mission/run context. + bound to a single lane worktree and caller context. ## Session model @@ -455,7 +455,7 @@ schema is used for: - interactive shell PTYs (`toolType = "shell"`) - managed processes launched by `processService` (`toolType = "run-shell"`) - tracked CLI agent terminals (`claude`, `codex`, `cursor-cli`, `droid`, - `opencode`, plus the `*-orchestrated` variants used by missions) + `opencode`) - agent chat sessions that run through the Claude/Codex/Cursor/Droid/ OpenCode SDKs rather than a PTY (`claude-chat`, `codex-chat`, `opencode-chat`, `cursor-chat`, `droid-chat`) diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index 56d683448..f176a174d 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -314,8 +314,8 @@ registry under `terminal.reattachChatCli`. `ptyService.hasLiveSessions()` walks the PTY map and returns true if any entry is not disposed. `main.ts`'s `hasActiveProjectWorkloads(ctx)` calls it (alongside -`agentChatService.hasRetainableSessions()` and the mission active-list -check) so a project context with running CLIs / agents / shells is +`agentChatService.hasRetainableSessions()` and managed-process +checks) so a project context with running CLIs / agents / shells is never evicted by the warm-idle cap. ### Send-or-continue (`sendToSession`) diff --git a/docs/features/terminals-and-sessions/runtime-isolation.md b/docs/features/terminals-and-sessions/runtime-isolation.md index 57f2166bd..8cc48f482 100644 --- a/docs/features/terminals-and-sessions/runtime-isolation.md +++ b/docs/features/terminals-and-sessions/runtime-isolation.md @@ -14,7 +14,7 @@ calls `fs.realpathSync` against the remote filesystem and refuses to spawn outside the remote worktree. The desktop renderer never bypasses the runtime to spawn directly. -This document covers the gating, fallback behavior, and per-mission +This document covers the gating, fallback behavior, and per-run scoping that makes "which work runs where" a deterministic answer. ## Lane gating: `resolveLaneLaunchContext` @@ -127,27 +127,12 @@ If the CLI assigns a new session ID at runtime, `ptyService` backfills it into `resumeMetadata.targetId` via the transcript scan and/or Claude/Codex local storage lookup. -## Mission scoping +## Worker scoping -Missions run through the orchestrator (`aiOrchestratorService`, -`missionLifecycle`) and launch PTYs or agent chats via the same -`ptyService.create` + `agentChatService` entry points. Each mission -step operates inside: - -- the mission's assigned lane (missions own a `laneId`) -- optionally, an integration lane for cross-lane simulation - -Missions do not bypass `resolveLaneLaunchContext`; they go through the -same gate, which means mission work stays inside its lane's worktree. - -Mission-level execution policy (see -`apps/desktop/src/shared/types/missions.ts` and -`orchestrator/executionPolicy.ts`) additionally controls: - -- which tools the agent may call -- permission mode (Claude `auto`/`plan`/`allow`, Codex approval - policies and sandbox modes) -- network/fs sandboxing flags passed through as resume metadata +Worker launches use the same `ptyService.create` and +`agentChatService` entry points as regular chats. Each worker operates +inside its assigned lane and does not bypass `resolveLaneLaunchContext`, +which means worker work stays inside the lane's worktree. ## Managed process scoping diff --git a/docs/features/workspace-graph/README.md b/docs/features/workspace-graph/README.md index 910d665fa..22933141a 100644 --- a/docs/features/workspace-graph/README.md +++ b/docs/features/workspace-graph/README.md @@ -52,7 +52,7 @@ Core renderer files (`apps/desktop/src/renderer/components/graph/`): Detail doc in this folder: -- [`data-sources.md`](./data-sources.md) — how lane/PR/mission state feeds the graph and what the hydration stages look like. +- [`data-sources.md`](./data-sources.md) — how lane and PR state feeds the graph and what the hydration stages look like. ## What the graph shows diff --git a/docs/perf/missions-tab-action-inventory.md b/docs/perf/missions-tab-action-inventory.md deleted file mode 100644 index 7c216d429..000000000 --- a/docs/perf/missions-tab-action-inventory.md +++ /dev/null @@ -1,306 +0,0 @@ -# Missions tab action inventory - -This is the audit matrix for Missions-tab autoresearch. It is deliberately not a -completion claim. A row is only `measured` when a real Missions-tab UI run has a -matching manual marker, an equivalent UI-derived probe against the perf-pass -repo, a Codex Computer Use observation recorded in this matrix, or a focused -test that reproduces the exact behavior. - -Coverage states: - -- `source`: found in source, not yet driven in the current inventory pass. -- `measured`: exact row covered by a real Missions UI run, UI-derived probe, - Codex Computer Use observation, or focused fixture test with evidence. -- `measured-partial`: driven in an earlier partial pass; must be re-driven by - this matrix before claiming full coverage. -- `fixture-needed`: safe to drive, but needs seeded mission/run/worker state. -- `sandbox-only`: may start agents, local tools, or mutate the perf-pass repo. -- `prompt-only`: destructive or externally visible path. Open and measure the - confirmation/preflight, then cancel unless explicitly allowed. -- `external-skip`: opens another app, browser, external service, or copies data - out of ADE. -- `not-applicable`: row came from an older/source-derived inventory item but - the current visible Missions surface no longer exposes that control. - -Comparison target from Factory Missions docs: - -- Collaborative planning must be conversational, not a one-shot prompt. -- Plans should break large work into features/milestones and validation points. -- Mission Control should show progress, workers, interventions, and re-planning. -- Users should be able to pause, redirect, unblock, and recover stuck work. -- Cost/duration should be explained in terms of worker and validation runs. - -Evidence used so far: - -- `missions-ui-audit-20260520-025200` -- `missions-ui-audit-perfpass-20260520-025349` -- Codex Computer Use observation on 2026-05-20: live Electron app - `/Users/admin/Projects/ADE/apps/desktop/node_modules/electron/dist/Electron.app`, - URL `localhost:5173/missions`, project `/Users/admin/Projects/perf pass`, - ADE window resized to `1460x880`. -- Codex Computer Use observation on 2026-05-20 of the completed FleetOps mission: - create dialog, settings dialog, mission selection, Conversations/Plan/Timeline/ - Artifacts tabs, artifact preview, completed worker history, and Manage dialog. -- Codex Computer Use observation on 2026-05-20 of the Factory-style create flow: - filled a large prompt, applied Codex low/full-auto, added a custom - Discovery / Requirements phase, enabled Ask Questions, reordered it between - Planning and Development, and ran Review Launch. Preflight passed with one - budget warning and no failures; launch was canceled at the boundary. -- Codex Computer Use observation on 2026-05-20 of live mission - `Planning policy smoke v11 in this perf-pass repo.`: - planner opened a blocking manual-input question, the answer was submitted - through the inline intervention panel, Planning phase approval was submitted, - workers ran through implementation/integration/testing/validation/closeout, - and closeout produced the ADE-local proof note `.ade/planning-smoke-v11.md`. - After the desktop build refreshed the single default `/tmp/ade-runtime-dev.sock` - runtime, the same mission rehydrated from `BLOCKED` to `DONE`. -- Live CDP/Codex Computer Use check on 2026-05-20 after HMR: `/missions` - exposed `[data-route="missions"]`, dashboard recent actions rendered `VIEW` - instead of inert `RERUN`/`RETRY`, and the ADE dev window stayed at `1460x880`. -- Large fixture pass on 2026-05-20 used only the existing attach-launched - Electron app on `localhost:5173` and the existing `/tmp/ade-runtime-dev.sock` - runtime. It did not launch a second dev app and did not touch - `/tmp/ade-runtime-dev-tui-perf-241a6c0b.sock`. The seeded perf-pass mission - `perf-large-mission-20260520` rendered in the sidebar at `132/132`, with - 132 steps, 132 attempts, 720 chat messages, 2,200 timeline events, 90 mission - artifacts, 90 orchestrator artifacts, 90 checkpoints, and 6 resolved - interventions. Attached CDP timings on the live window: `missions.list.ipc` - 3ms, `missions.dashboard.ipc` 4ms, `missions.fullView.large.ipc` 25ms; - tab render probes showed Overview 624 nodes, Conversations 850, Plan 4,635 - with 17 compact `SHOW MORE` controls after optimization, Timeline 1,929, - Artifacts 2,554, heap about 113MB after 5s idle. -- Focused unit coverage added on 2026-05-20 for Smart Budget default hydration, - Smart Budget settings persistence, dashboard recent-action labels, and - phase-approval steering transitions. Focused Plan coverage also asserts that - large missions compact duplicate step lists until expanded. -- Steer/replan fixture pass on 2026-05-20 used only the existing - attach-launched Electron app and perf-pass DB. Mission - `perf-steer-mission-20260520` rendered as `BLOCKED`, `2/5`, with one open - manual-input intervention, a paused run, one `coordinator_steering` runtime - event, one `plan_revised` event, and one `intervention_opened` event. Codex - Computer Use observed the inline intervention, Conversations feed, Timeline - entries for `Plan revised` and `User steering`, and Plan state with one - succeeded step, one superseded broad dashboard step, one ready replacement - schedule-risk step, one blocked operator-review step, and one pending - validation step. A post-build CUA check also verified selecting the mission - rewrites the URL to `missionId=perf-steer-mission-20260520`, so reloads no - longer fall back to the previous large fixture. No workers or second dev - runtime were launched. -- Focused tests from the current worktree listed in each row. - -## Route shell and dashboard - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.route.open | Open Missions route on perf-pass project | measured | CUA 2026-05-20 observed `localhost:5173/missions` with project `perf pass`; source `MissionsPage.tsx` | -| missions.route.resize | Verify ADE window stays larger than default after restart | measured | CUA 2026-05-20; System Events reported ADE at `1460x880` after Electron PID restarts | -| missions.dashboard.home.empty | Render no-selection dashboard / empty active missions | measured-partial | `missions-ui-audit-perfpass-20260520-025349`, marker `missions-home-empty`; CUA 2026-05-20 showed dashboard cards | -| missions.dashboard.recent.select | Select recent mission from dashboard | measured | `MissionsHomeDashboard.test.tsx`; live CDP 2026-05-20 showed recent actions as `VIEW` | -| missions.dashboard.rerun | Click recent mission `RERUN` | not-applicable | Dashboard recent actions were relabeled to `VIEW` because the control only selects a mission; `MissionsHomeDashboard.test.tsx` | -| missions.dashboard.new | Click dashboard `NEW MISSION` | source | `MissionsHomeDashboard.tsx`, `missionCreateDialogStore.ts` | -| missions.route.inactive | Switch away and confirm Missions polling quiets while inactive | fixture-needed | `MissionActiveContext.tsx`, `useMissionRunView.ts`, docs/features/missions/README.md | - -## Sidebar and mission list - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.sidebar.refresh | Click sidebar Refresh | measured | CUA 2026-05-20 clicked Refresh after direct fixture seeding; total updated without restarting ADE; `MissionSidebar.tsx`; store coverage `useMissionsStore.test.ts` | -| missions.sidebar.settings.open | Open Mission Settings from sidebar gear | measured | CUA 2026-05-20 opened settings modal; `MissionSidebar.tsx`, `MissionSettingsDialog.tsx` | -| missions.sidebar.new | Open New Mission from sidebar plus button | measured | CUA 2026-05-20 opened Create Mission modal; `MissionSidebar.tsx`, `CreateMissionDialog.tsx` | -| missions.sidebar.search.match | Type a matching mission search | source | `MissionSidebar.tsx` | -| missions.sidebar.search.nomatch | Type a no-match mission search and clear | source | `MissionSidebar.tsx` | -| missions.sidebar.view.list | Switch to list view | source | `MissionSidebar.tsx`; virtualized list `data-testid=mission-list-virtual` | -| missions.sidebar.view.board | Switch to board view | source | `MissionSidebar.tsx`; board view is not virtualized | -| missions.sidebar.select | Select mission from list | measured | CUA 2026-05-20 selected completed FleetOps mission and steer/replan fixture from list; post-build CUA verified list selection synced the `missionId` URL query | -| missions.sidebar.context.open | Right-click mission list item context menu | source | `MissionSidebar.tsx`, `ManageMissionDialog.tsx` | -| missions.sidebar.context.open-mission | Context menu Open Mission | source | `MissionContextMenu` | -| missions.sidebar.context.manage | Context menu Manage Mission | source | `MissionContextMenu`, `ManageMissionDialog.tsx` | -| missions.sidebar.status.block | Selected mission status block updates title/status/phase/open counts | measured | helper tests in `missionHelpers.test.ts`; CUA 2026-05-20 showed selected mission title, completed state, phase badge, open count, and artifact count | - -## Mission creation dialog - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.create.open | Open Create Mission dialog | measured | CUA 2026-05-20 opened modal from sidebar plus button | -| missions.create.prompt | Fill a large mission prompt | measured | CUA 2026-05-20 filled Factory-style launch-operations mission prompt in Create Mission dialog | -| missions.create.models.codex-low | Apply Codex low/full-auto preset | measured | CUA 2026-05-20 showed orchestrator and phases set to `openai/gpt-5.5 · low`; helper coverage in `CreateMissionDialog.test.ts` | -| missions.create.models.phase-sync | Apply one model config to all phases | measured | `CreateMissionDialog.test.ts` | -| missions.create.profile.select | Select a phase profile | source | `CreateMissionDialog.tsx` | -| missions.create.phase.add-custom | Add custom phase before closeout | measured | `CreateMissionDialog.test.ts` | -| missions.create.phase.edit | Edit phase key/name/instructions/model/validation/questions | measured | CUA 2026-05-20 configured custom Discovery / Requirements name, key, description, instructions, Ask Questions, and phase order; persistence coverage in `missionService.test.ts` | -| missions.create.phase.required-question | Configure Planning to require a blocking question before exit | measured | backend and launch-request coverage in `missionService.test.ts`, `coordinatorTools.test.ts`, `orchestratorService.test.ts` | -| missions.create.permissions | Edit worker permissions / sandbox controls | measured-partial | CUA 2026-05-20 confirmed Codex low/full-auto preset set Codex workers to full access; direct editor controls still need UI pass | -| missions.create.budget.smart | Toggle smart budget panel and budget caps | measured | `CreateMissionDialog.test.ts` covers Smart Budget defaults in new mission launch drafts; `useMissionsStore.test.ts` covers hydrate/save to project config | -| missions.create.team-runtime | Change max workers / parallelism settings | source | `CreateMissionDialog.tsx` | -| missions.create.preflight | Run launch preflight and inspect warnings/blockers | measured | CUA 2026-05-20 Review Launch passed for 7-phase Factory-style mission with `Planning -> Discovery / Requirements -> Development -> Integration -> Testing -> Validation -> Closeout`; one budget warning, zero failures | -| missions.create.launch | Launch a mission from the dialog | sandbox-only | Starts agents and mutates perf-pass repo | -| missions.create.high-teammate-confirm | Confirm high teammate/parallelism warning | fixture-needed | `CreateMissionDialog.tsx` | -| missions.create.close | Close dialog without launch | measured | CUA 2026-05-20 closed modal with Cancel and returned to dashboard | - -## Mission settings dialog - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.settings.open | Open Mission Settings dialog | measured | CUA 2026-05-20 opened settings modal from sidebar gear | -| missions.settings.defaults | Change default profile / default models | measured-partial | CUA 2026-05-20 observed default model, teammate plan, worker permissions, smart budget, and profile list; `useMissionsStore.test.ts` covers Smart Budget config hydration/persistence | -| missions.settings.profile.clone | Clone a phase profile | source | `MissionSettingsDialog.tsx` | -| missions.settings.profile.edit | Edit profile name/description/phases | source | `MissionSettingsDialog.tsx`, `PhaseCardEditor.tsx` | -| missions.settings.profile.export | Export selected phase profile | external-skip | File dialog / download-like path in `MissionSettingsDialog.tsx` | -| missions.settings.profile.import | Import phase profile JSON | external-skip | File picker path in `MissionSettingsDialog.tsx` | -| missions.settings.profile.delete | Delete phase profile | prompt-only | `MissionSettingsDialog.tsx` | -| missions.settings.save | Save settings | measured-partial | Backend coverage in `missionService.test.ts`; `useMissionsStore.test.ts` covers renderer settings save into local project config; live UI save pass missing | -| missions.settings.close | Close settings without saving | measured | CUA 2026-05-20 closed settings modal without saving | - -## Header and lifecycle controls - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.header.manage | Open Manage Mission from header | measured | CUA 2026-05-20 opened Manage dialog from terminal mission header | -| missions.header.start | Start a not-started mission | sandbox-only | `MissionHeader.tsx` | -| missions.header.rerun | Rerun terminal mission | sandbox-only | `MissionHeader.tsx`; CUA 2026-05-20 showed a terminal mission with RERUN available | -| missions.header.pause | Pause active run from header | prompt-only | Mutates active run; `MissionHeader.tsx` | -| missions.header.resume | Resume paused run from header | prompt-only | Mutates active run; `MissionHeader.tsx` | -| missions.header.cancel | Cancel active mission from header | prompt-only | Header has `window.confirm`; `MissionHeader.tsx` | -| missions.header.archive | Archive terminal mission from header | prompt-only | Header has `window.confirm`; `MissionHeader.tsx` | -| missions.manage.pause | Pause run from Manage dialog | prompt-only | `ManageMissionDialog.tsx` | -| missions.manage.resume | Resume run from Manage dialog | prompt-only | `ManageMissionDialog.tsx` | -| missions.manage.cancel | Cancel mission from Manage dialog | prompt-only | `ManageMissionDialog.tsx`; confirmation parity covered by `ManageMissionDialog.test.tsx` | -| missions.manage.archive | Archive mission from Manage dialog | prompt-only | CUA 2026-05-20 observed terminal archive modal; confirmation parity covered by `ManageMissionDialog.test.tsx` | -| missions.manage.cleanup-lanes | Toggle "also archive lanes" and archive | prompt-only | CUA 2026-05-20 observed lane-cleanup checkbox; `ManageMissionDialog.test.tsx` covers cleanup-specific confirmation text | - -## Overview tab - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.overview.open | Open Overview tab | source | `MissionTabContainer.tsx`; tab ARIA coverage in `MissionTabContainer.test.tsx` | -| missions.overview.office | Inspect mission control office panel | source | `MissionControlOfficePanel.tsx`, `missionControlViewModel.test.ts` | -| missions.overview.run-panel | Inspect run panel phase/worker state | source | `MissionRunPanel.tsx`, `missionControlViewModel.test.ts` | -| missions.overview.intervention.open | Open intervention from overview panels | fixture-needed | Requires open intervention; `routeMissionIntervention` | -| missions.overview.prompt-inspect | Inspect coordinator prompt from active phase panel | fixture-needed | `MissionActivePhasePanel.tsx`, `PromptInspectorCard.tsx` | -| missions.overview.brief-scroll | Scroll long mission brief | source | `MissionTabContainer.tsx` | - -## Conversations tab - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.chat.open | Open Conversations tab | measured | CUA 2026-05-20 opened tab on completed FleetOps mission | -| missions.chat.global | Select global mission feed | measured | CUA 2026-05-20 observed read-only Mission Feed with worker results, tests, and closed-run warning | -| missions.chat.orchestrator | Select orchestrator channel | measured | CUA 2026-05-20 selected Orchestrator transcript with load-older control and closed-run composer block | -| missions.chat.worker.active | Select active worker channel | fixture-needed | Needs active worker | -| missions.chat.worker.completed | Expand/select completed worker history | measured | CUA 2026-05-20 expanded Completed (8) and selected Integration worker read-only transcript; `MissionThreadMessageList.test.ts` verifies closed worker history hydrates from durable Agent Chat events; `missionChatChannelModel.test.ts` covers stale/completed channel logic | -| missions.chat.completed.collapse | Collapse completed workers section | source | `ChatChannelList.tsx` | -| missions.chat.load-older | Load older messages in a thread | fixture-needed | `MissionChatV2.tsx` | -| missions.chat.raw-tail.live-worker | Poll raw transcript tail only for live worker thread | measured | `ChatMessageArea.test.ts`, `MissionThreadMessageList.test.ts` | -| missions.chat.message.orchestrator | Message orchestrator / steer mission | sandbox-only | Sends message into runtime; `MissionChatV2.tsx`, `ChatInput.tsx` | -| missions.chat.message.worker | Message active worker | sandbox-only | Sends message into worker thread; `MissionChatV2.tsx`, `ChatInput.tsx` | -| missions.chat.message.blocked | Verify composer disabled on completed worker/global/history | measured | CUA 2026-05-20 observed closed-run warning and read-only composer footer on Mission Feed, Orchestrator, and completed worker history | -| missions.chat.mention | Append mention target from composer chips | source | `ChatInput.tsx` | -| missions.chat.controls.pause | Pause active run from chat controls | prompt-only | `MissionChatV2.tsx` | -| missions.chat.controls.resume | Resume paused run from chat controls | prompt-only | `MissionChatV2.tsx` | -| missions.chat.controls.cancel | Cancel run from chat controls | prompt-only | `MissionChatV2.tsx` | -| missions.chat.jump.worker | Jump from Plan selected step to worker channel | measured | CUA 2026-05-20 reproduced stale shared-lane misroute, then verified Plan `Planning worker` jumps to `Planning: Planning worker`; regression coverage in `missionChatChannelModel.test.ts` | - -## Plan tab - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.plan.open | Open Plan tab | measured | CUA 2026-05-20 opened Plan tab on completed FleetOps mission, the 132-step synthetic fixture, and the steer/replan fixture | -| missions.plan.summary | Inspect plan summary/assumptions/risks | measured | CUA 2026-05-20 observed planner review, assumptions/risks, milestone grouping, dependency DAG, large-fixture compact groups, and steer/replan replacement state | -| missions.plan.phase.expand | Inspect phase-grouped step list | measured | CUA 2026-05-20 observed Planning, Development, Integration, Testing, Validation step groups; large fixture shows first 10 steps per phase with `SHOW MORE` expansion controls; steer fixture shows succeeded, superseded, ready, blocked, and pending steps | -| missions.plan.step.select | Select a plan step | measured | CUA 2026-05-20 observed selected Planning worker detail panel and latest worker attempt | -| missions.plan.step.output | Toggle full step output | source | `StepDetailPanel.tsx` | -| missions.plan.step.prompt | Inspect selected step effective prompt | fixture-needed | `StepDetailPanel.tsx`, `PromptInspectorCard.tsx` | -| missions.plan.step.worker-jump | Jump selected step to worker thread | measured | `missionChatChannelModel.test.ts`; live UI pass missing | -| missions.plan.dag.select | Select step from dependency graph | source | `OrchestratorDAG.tsx`, `PlanTab.tsx` | -| missions.plan.progress | Phase progress ignores canceled/display-only task shells correctly | measured | `PlanTab.test.ts`, `missionHelpers.test.ts`, `missionControlViewModel.test.ts`; `PlanTab.test.ts` also covers large-plan list compaction | - -## Timeline and logs tab - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.timeline.open | Open Timeline tab | measured | CUA 2026-05-20 opened Timeline tab and observed summary, filters, raw toggle, event rows, run narrative, `Plan revised`, and `User steering` entries | -| missions.timeline.category | Change timeline category filter | source | `OrchestratorActivityFeed.tsx` | -| missions.timeline.search | Search/filter timeline events | source | `OrchestratorActivityFeed.tsx` | -| missions.timeline.severity | Change severity chips | source | `OrchestratorActivityFeed.tsx` | -| missions.timeline.reset | Reset timeline filters | source | `OrchestratorActivityFeed.tsx` | -| missions.timeline.raw-events | Toggle raw maintenance events | source | `OrchestratorActivityFeed.tsx` | -| missions.timeline.expand | Expand grouped/individual event details | source | `OrchestratorActivityFeed.tsx` | -| missions.timeline.raw-logs.open | Toggle Show raw logs | source | `MissionTabContainer.tsx`, `MissionLogsTab.tsx` | -| missions.logs.channel-filter | Toggle raw log channels | source | `MissionLogsTab.tsx` | -| missions.logs.refresh | Refresh raw logs | source | `MissionLogsTab.tsx` | -| missions.logs.export | Export raw logs | external-skip | Browser/download/clipboard boundary; `MissionLogsTab.tsx` | -| missions.logs.load-more | Load older raw logs | fixture-needed | Needs >200 events | -| missions.timeline.presentation | Suppress low-signal internal chatter and humanize kept entries | measured | `missionFeedPresentation.test.ts`, `MissionThreadMessageList.test.ts` | - -## Artifacts and proof tab - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.artifacts.open | Open Artifacts tab | measured | CUA 2026-05-20 opened Artifacts tab on completed FleetOps mission | -| missions.artifacts.group.phase | Group artifacts by phase | measured | CUA 2026-05-20 observed phase grouping with closeout expected evidence | -| missions.artifacts.group.type | Group artifacts by type | source | `MissionArtifactsTab.tsx` | -| missions.artifacts.group.worker | Group artifacts by worker | measured | Added `WORKER` grouping in `MissionArtifactsTab.tsx`; `missionControlViewModel.test.ts` asserts attempt/worker grouping for orchestrator, checkpoint, and mission artifacts | -| missions.artifacts.requirements.optional | Toggle optional closeout requirements | source | `MissionArtifactsTab.tsx` | -| missions.artifacts.select | Select artifact and preview | measured | CUA 2026-05-20 selected Integration test results and previewed `npm test`/`npm run build` output | -| missions.artifacts.open-uri | Open URI-backed artifact | external-skip | `MissionArtifactsTab.tsx` | -| missions.artifacts.computer-use | Inspect computer-use proof panel/snapshot | fixture-needed | `MissionComputerUsePanel.tsx` | -| missions.artifacts.grouping | Merge mission/orchestrator/checkpoint/missing expected evidence | measured | `missionControlViewModel.test.ts` | - -## Interventions and planning questions - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.intervention.panel | See open intervention in intervention panel | measured | CUA 2026-05-20 v11 mission exposed the planner's manual-input question inline after selected mission detail rehydration; steer/replan fixture also showed the open manual-input approval panel; regression coverage in `useMissionsStore.test.ts` | -| missions.intervention.manual.open | Open manual input response modal | measured | CUA 2026-05-20 v11 mission used the inline manual-input panel; `ManualInputResponseModal.tsx`, `MissionDetailView.tsx` | -| missions.intervention.manual.answer | Submit answer to manual input | measured | CUA 2026-05-20 answered `Documentation-only...` and clicked `RESOLVE`; DB showed manual_input resolved and mission resumed; regression coverage in `useMissionsStore.test.ts` | -| missions.intervention.quiz.open | Open clarification quiz modal | fixture-needed | `ClarificationQuizModal.tsx` | -| missions.intervention.quiz.answer | Submit quiz answers / default assumptions | sandbox-only | Calls `steerMission`; `MissionDetailView.tsx` | -| missions.intervention.phase-approval.resolve | Approve a phase transition intervention and continue into the target phase | measured | CUA 2026-05-20 v11 approved Planning; `aiOrchestratorService.test.ts` verifies `steerMission` updates `phaseRuntime` and records a `phase_transition` event | -| missions.planning.question.required | Planner skipping required blocking question pauses run and opens intervention | measured | `orchestratorService.test.ts`, `coordinatorTools.test.ts`, `planningQuestionPolicy.ts`; managed ADE chat `request_user_input` answers are persisted as resolved planning clarification so the phase gate does not ask a duplicate generic question | -| missions.planning.question.resolve | Answer required planning question and retry planning | measured | CUA 2026-05-20 v11 answered planner question; `aiOrchestratorService.test.ts`, `workerTracking.ts`, `useMissionsStore.test.ts` | - -## Final large mission proof - -| id | action | state | evidence | -| --- | --- | --- | --- | -| missions.final.create-large | Create a large Factory-style mission with custom phases | measured-partial | CUA 2026-05-20 created and preflighted the large Factory-style mission, then canceled at launch boundary; still needs final launch/run proof | -| missions.final.planning-questions | Verify planning asks and user answers blocking questions | measured | CUA 2026-05-20 v11 planner asked one blocking question; answer resolved through inline intervention; stale detail and intervention-identity refresh regressions covered by `useMissionsStore.test.ts` | -| missions.final.delegate-workers | Verify coordinator delegates to workers only when useful | measured | CUA/DB 2026-05-20 v11 launched Planning, Implementation, integration no-op, Testing, Validation, validator, and Closeout workers; single-lane integration correctly avoided merge work | -| missions.final.worker-chat | Verify orchestrator/worker/worker-like conversations are visible and readable | measured | CUA 2026-05-20 v11 Mission Feed showed operator decisions, validation signals, worker results, changed files, and test summaries; completed worker history visible | -| missions.final.replan-intervene | Verify pause/steer/replan path on a live or fixture mission | measured | CUA/DB 2026-05-20 on `perf-steer-mission-20260520`: paused run, one open manual-input approval, `coordinator_steering`, `plan_revised`, and `intervention_opened` timeline/runtime events; Plan showed superseded broad dashboard work, ready schedule-risk replacement, blocked operator review, and pending validation. Regression coverage in `aiOrchestratorService.test.ts` and `missionHelpers.test.ts` | -| missions.final.result-lane | Verify mission finalizes into a result lane and summarizes work | measured | CUA/DB 2026-05-20 v11 result lane `a72a5379-0679-43fa-bfc8-e94e2fee9937`; finalization blocked state preserved until risk-note closeout inference patch; result-lane conflict regression covered in `aiOrchestratorService.test.ts` | -| missions.final.performance | Verify UI remains responsive under a large mission history | measured | CUA/CDP 2026-05-20 on synthetic 132-step fixture: list 3ms, dashboard 4ms, fullView 25ms, tab panes rendered successfully, heap held near 113MB after 5s idle; Plan duplicate step lists compacted by default; official ADE_PERF launch remains optional because it would start a separate runner | - -## Current highest-risk unproven rows - -- Full create dialog UI from open -> prompt -> custom phases -> preflight is - covered by CUA; launch remains intentionally pending until the runtime - hardening checks pass. -- Planning question enforcement and the live UI intervention answer/retry path - are covered by the v11 mission proof. The follow-up bug was stale selected - mission detail when open intervention count or identity changed. -- Phase approval is now covered at both the v11 UI boundary and service level: - approving a `phase_approval` intervention updates the run `phaseRuntime` and - records a `phase_transition` timeline event. -- Large-read-side pressure is covered by the 132-step synthetic mission fixture. - The Plan tab had the largest DOM pressure, so duplicate group/phase step lists - now compact by default on large missions while keeping the dependency DAG and - explicit expansion controls available. -- Pause, steer, and replan read-side behavior is covered by the steer/replan - fixture. The service now records steering as runtime/timeline events, and the - timeline helper renders both `Plan revised` and `User steering` entries for - operator recovery context. -- Manage dialog destructive actions now have confirmation parity tests; a live - prompt-only confirmation pass remains intentionally canceled unless allowed. -- Timeline/logs/artifacts have helper and completed-mission coverage; worker - artifact grouping is now implemented, and the large fixture now covers - 2,200 timeline events plus 90 mission artifacts / 90 orchestrator artifacts - on the live read side. -- The already-running default dev runtime on `/tmp/ade-runtime-dev.sock` - initially displayed stale closeout-blocked state for v11; after the standard - desktop build refreshed that single runtime, the mission rehydrated as DONE. - No second runtime was launched, and the TUI socket - `/tmp/ade-runtime-dev-tui-perf-241a6c0b.sock` was left untouched. -- Missions performance probes are now registered. The current proof is attached - CDP/CUA against the already-running app; an official ADE_PERF run is optional - and should use a unique socket/CDP port if run later. diff --git a/getting-started/connect-provider.mdx b/getting-started/connect-provider.mdx index 8c9ff11c9..f74186954 100644 --- a/getting-started/connect-provider.mdx +++ b/getting-started/connect-provider.mdx @@ -1,6 +1,6 @@ --- title: "Connect an AI provider" -description: "Add a provider credential so ADE can start agents and missions." +description: "Add a provider credential so ADE can start agents and worker runs." icon: "key" --- @@ -20,7 +20,7 @@ ADE works best once you have at least one verified provider configured. There ar - Best default — works with chat, missions, CTO, and automations. Keys start with `sk-ant-`. + Best default — works with chat, worker runs, CTO, and automations. Keys start with `sk-ant-`. Good for structured output and GPT-family models. Keys start with `sk-`. @@ -71,7 +71,7 @@ ADE works best once you have at least one verified provider configured. There ar | If you want... | Use this | Why | |----------------|----------|-----| -| The most complete ADE experience | **Anthropic (Claude)** | Works everywhere — chat, missions, CTO, automations | +| The most complete ADE experience | **Anthropic (Claude)** | Works everywhere — chat, worker runs, CTO, automations | | You already use Cursor daily | **Cursor** | ADE integrates through the Cursor SDK with a Cursor API key | | Cost flexibility | **OpenRouter** | Route different agent roles to different models | | Privacy / offline work | **Ollama (local)** | No API key, runs entirely on your machine | diff --git a/getting-started/first-agent.mdx b/getting-started/first-agent.mdx index 10c525831..a32b8eeee 100644 --- a/getting-started/first-agent.mdx +++ b/getting-started/first-agent.mdx @@ -1,6 +1,6 @@ --- title: "Run your first agent" -description: "Choose between a direct chat and a structured mission for your first ADE task — with step-by-step instructions for each approach and guidance on what to try first." +description: "Choose between a direct chat and a structured worker run for your first ADE task — with step-by-step instructions for each approach and guidance on what to try first." icon: "robot" --- @@ -45,12 +45,12 @@ You have a lane ready. Now it is time to run an agent in it. ADE offers two path If the agent asks a question, a question modal appears in the chat. Read the options, select your answer, and the agent continues. This is ADE's **AskUserQuestion** flow — the agent pauses cleanly instead of guessing when it needs your input. - - Use a Mission when the task needs a formal plan, parallel workers, validation, or a stronger audit trail. Missions are overkill for a first test — but if you want to see ADE's full orchestration, here is how. + + Use a Worker run when the task needs a formal plan, parallel workers, validation, or a stronger audit trail. Workers are overkill for a first test — but if you want to see ADE's full orchestration, here is how. - - Click **Missions** in the left sidebar. Click **+ New Mission**. + + Click **Workers** in the left sidebar. Click **+ New Worker run**. Write a clear, specific goal. The planner agent uses this to generate a structured execution plan. @@ -58,21 +58,21 @@ You have a lane ready. Now it is time to run an agent in it. ADE offers two path Example: *"Add a REST endpoint `GET /api/health` that returns `{ status: 'ok' }` with a corresponding test."* - Select **Supervised** for your first mission. This pauses for your approval before the agent starts executing — so you can review the plan before any code is written. + Select **Supervised** for your first worker run. This pauses for your approval before the agent starts executing — so you can review the plan before any code is written. The planner generates a step-by-step execution plan. Review the steps, lane assignments, and estimated cost. Click **Approve** to proceed or **Edit** to adjust. - Workers execute the plan. The Mission Run panel shows: + Workers execute the plan. The worker detail panel shows: - Live status of each step (queued, running, completed, failed) - Terminal output from each worker - File changes and tool call logs - When all steps complete, the mission enters validation. + When all steps complete, the worker run enters validation. - Check the Artifacts tab for test reports, code changes, and summaries. Create a PR from the mission's result lane if the work looks good. + Check the Artifacts tab for test reports, code changes, and summaries. Create a PR from the worker run's result lane if the work looks good. @@ -154,13 +154,13 @@ Once you have verified the result: - Understand every core concept in ADE: Lanes, Packs, Missions, the CTO, Automations, and more. + Understand every core concept in ADE: Lanes, Packs, Workers, the CTO, Automations, and more. - See the full list of what the chat agent can do — file operations, git, PRs, missions, and more. + See the full list of what the chat agent can do — file operations, git, PRs, worker runs, and more. - - Learn how to configure multi-step missions with planning, validation, and cost tracking. + + Learn how to configure multi-step worker runs with planning, validation, and cost tracking. Run parallel agents across multiple lanes and monitor them from the Workspace Graph. diff --git a/getting-started/first-lane.mdx b/getting-started/first-lane.mdx index 87160c0cf..bf5daaa91 100644 --- a/getting-started/first-lane.mdx +++ b/getting-started/first-lane.mdx @@ -92,7 +92,7 @@ Prefer one lane per feature, fix, or experiment. If a task grows beyond what you - Use Agent Chat for a quick task or launch a Mission for structured execution. + Use Agent Chat for a quick task or launch a Worker run for structured execution. Learn about lane types, status indicators, the 3-pane layout, and health monitoring. diff --git a/getting-started/open-project.mdx b/getting-started/open-project.mdx index 7d99624de..bc3fac7d2 100644 --- a/getting-started/open-project.mdx +++ b/getting-started/open-project.mdx @@ -29,12 +29,12 @@ When ADE opens a repository for the first time, it creates a `.ade/` directory a ``` .ade/ - artifacts/ ← packs, proofs, and mission outputs + artifacts/ ← packs, proofs, and worker run outputs packs/ lanes/ ← lane artifacts and compatibility snapshots features/ ← feature artifacts (Linear-linked) conflicts/ ← conflict resolution context - missions/ ← mission plans and phase snapshots + worker runs/ ← worker run plans and phase snapshots cto/ ← CTO identity and configuration worktrees/ ← git worktrees for lanes (one per lane) db/ ← SQLite database for lanes, sessions, history @@ -82,7 +82,7 @@ You can skip any step and configure it later in **Settings**. For a detailed wal Once the wizard completes, ADE loads the project and you land on the **Lanes** view. You will see: - **Primary Lane** — your main repository checkout, always present and read-only by default -- **Left sidebar** — navigation to Lanes, CTO, Missions, Automations, History, and Settings +- **Left sidebar** — navigation to Lanes, CTO, Workers, Automations, History, and Settings - **Right inspector** — context-sensitive detail panel showing lane info, packs, and environment From here, [create your first lane](/getting-started/first-lane) and [run your first agent](/getting-started/first-agent). diff --git a/getting-started/project-setup.mdx b/getting-started/project-setup.mdx index 8cc0f25cd..3b457d057 100644 --- a/getting-started/project-setup.mdx +++ b/getting-started/project-setup.mdx @@ -42,7 +42,7 @@ Connect at least one AI provider so agents can run. ADE supports: ADE runs a health check on each provider. A green indicator means the provider is ready; amber means the CLI is installed but not authenticated; red means the key is invalid or the CLI is missing. -Start with one provider. You can add more any time in **Settings → AI**. Anthropic (Claude) is recommended for the most complete ADE experience — it works with chat, missions, CTO, and automations out of the box. +Start with one provider. You can add more any time in **Settings → AI**. Anthropic (Claude) is recommended for the most complete ADE experience — it works with chat, worker runs, CTO, and automations out of the box. --- @@ -91,7 +91,7 @@ Connect Linear for bidirectional issue tracking between ADE and your Linear work | **Sync direction** | Bidirectional (default), read-only, or disabled | | **Default team** | Which Linear team receives new issues from ADE | -When connected, the CTO agent can read Linear issues, update status, and create new issues. Missions can link to Linear issues for full traceability. +When connected, the CTO agent can read Linear issues, update status, and create new issues. Workers can link to Linear issues for full traceability. Linear integration is most valuable when paired with the CTO agent. The CTO can ingest new Linear issues and automatically delegate them to worker agents in dedicated lanes. @@ -108,7 +108,7 @@ Once you finish (or skip) the wizard, ADE takes you to the **Run** tab. From her Fork a task into its own isolated worktree. - Use chat for a quick task or launch a mission for structured execution. + Use chat for a quick task or launch a worker run for structured execution. Configure your dev stack so ADE can start everything with one button. diff --git a/guides/automation-rules.mdx b/guides/automation-rules.mdx index 310c04074..eb0c28305 100644 --- a/guides/automation-rules.mdx +++ b/guides/automation-rules.mdx @@ -68,7 +68,6 @@ The executor determines how the triggered work runs. Choose one: | Executor | Best for | Output location | |---|---|---| | **Agent Session** | Quick, focused tasks (linting, reviews, small fixes) | Automations > History | -| **Mission** | Multi-step work requiring planning, validation, and artifacts | Missions tab | | **Built-in Task** | Predefined ADE operations (pack refresh, conflict scan) | Automations > History | For agent sessions, you provide a **prompt template** — the instruction the agent receives when the trigger fires. Use `{{branch}}`, `{{files}}`, and `{{event}}` placeholders to inject trigger context into the prompt. @@ -137,7 +136,7 @@ Check **Automations > History** to see past runs. Each entry shows: - Trigger event that fired the rule - Executor type and run duration - Status (completed, failed, budget-exceeded) -- Link to the full output (agent session transcript or mission) +- Link to the full output (agent session transcript or automation log) --- diff --git a/guides/cto-linear-workflow.mdx b/guides/cto-linear-workflow.mdx index 35cc3011a..97e8282d3 100644 --- a/guides/cto-linear-workflow.mdx +++ b/guides/cto-linear-workflow.mdx @@ -54,8 +54,7 @@ The CTO can route incoming Linear issues to different execution surfaces: | Target | When to use | |--------|------------| | `employee_session` | Simple tasks a single worker can handle in one session | -| `worker_run` | Tasks that need a dedicated lane but not full mission structure | -| `mission` | Complex multi-step work requiring planning, validation, and audit | +| `worker_run` | Tasks that need a dedicated lane, durable status, and audit | | `pr_resolution` | PR review comments or failing checks that need fixing | | `review_gate` | Work that should be reviewed by the CTO before proceeding | @@ -69,7 +68,7 @@ When new issues arrive in Linear, the CTO can triage them: - **Read the issue** — the CTO reads the title, description, and any comments - **Assess complexity** — estimates effort and identifies which parts of the codebase are involved -- **Propose a plan** — suggests whether the issue should be a mission, a single-lane task, or deferred +- **Propose a plan** — suggests whether the issue should be a worker run, PR fix, review gate, or deferred - **Assign priority** — updates the Linear issue with a recommended priority if auto-triage is enabled In Supervised mode, you review the CTO's triage in its chat interface before any work begins. In Auto-delegate mode, the CTO proceeds directly to delegation. @@ -92,7 +91,7 @@ When the CTO decides to act on an issue, it: -If the issue is complex enough to warrant a mission, the CTO creates one instead of a single worker session. +For larger issues, the CTO splits the work into dedicated lanes and worker runs. --- @@ -104,7 +103,7 @@ As work progresses, ADE keeps Linear updated: |---|---| | Worker starts | Issue moves to **In Progress** | | PR opened | Issue linked to the PR, comment added | -| Mission completed | Issue moves to **Done**, summary comment added | +| Worker completed | Issue moves to **Done**, summary comment added | | Worker failed | Issue flagged with a comment describing the failure | | Intervention needed | Issue comment requesting human input | @@ -116,7 +115,7 @@ Configure status mappings in **Settings → Integrations → Linear**: |-----------|----------------------|--------------| | Worker started | In Progress | Yes | | PR opened | In Review | Yes | -| Mission completed | Done | Yes | +| Worker completed | Done | Yes | | Worker failed | Blocked | Yes | | Intervention needed | Blocked | Yes | @@ -126,11 +125,11 @@ You can also configure `activeStateTypes` (which Linear states trigger CTO atten ## 6. Review and Close -When a worker or mission completes: +When a worker completes: - The CTO posts a summary comment on the Linear issue with links to the PR and any artifacts - The issue status is updated based on the outcome -- You can review the work in ADE's Mission or History views, then close the issue in Linear +- You can review the work in ADE's History view or PR panel, then close the issue in Linear --- @@ -148,7 +147,7 @@ When a worker or mission completes: Understand the CTO's role and org chart model. - - Learn how missions work — the execution unit the CTO uses for complex issues. + + Learn how CTO workers pick up delegated tasks and report progress. diff --git a/guides/first-mission.mdx b/guides/first-mission.mdx deleted file mode 100644 index 81b5a01d7..000000000 --- a/guides/first-mission.mdx +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Guide: Your first mission" -sidebarTitle: "Your First Mission" -description: "A step-by-step walkthrough of creating and running a multi-step mission in ADE." -icon: "graduation-cap" ---- - -Missions are ADE's structured execution unit for complex, multi-step AI work. This guide walks you through creating your first mission, reviewing its plan, and monitoring workers as they execute. - -Before starting, make sure you have at least one AI provider configured in **Settings → AI** and a project open with a valid `ade.yaml`. - - -If you are running a packaged (production) build, the Missions tab will show a "Coming soon" preview and the New Mission button is disabled. Mission creation and live runs are currently only available in dev and internal builds — this guide applies as-is there. The shipping version will use the same flow. - - ---- - -## 1. Create a Mission - -Navigate to the **Missions** tab in the sidebar. Click **New Mission** to open the creation dialog. - - - - Write a clear, specific goal for your mission. The planner agent uses this to generate a structured execution plan. - - Good goals are concrete: *"Add a REST endpoint for user preferences with validation, tests, and OpenAPI docs"* is better than *"work on the API."* - - - - **Supervised** — ADE pauses for your approval before executing the plan. Recommended for your first mission. - - **Autopilot** — planning and execution proceed without human gates. - - - Set a maximum token or dollar spend for the mission. If the cap is reached, the mission pauses with a `budget_limit_reached` intervention instead of continuing. - - - -Click **Create** to queue the mission. - ---- - -## 2. Review the Plan - -Once the planner agent finishes, ADE presents the generated plan for review. The plan includes: - -- **Steps** — numbered tasks the workers will execute -- **Lane assignments** — which lane each step runs in -- **Dependencies** — ordering constraints between steps -- **Parallelism** — which steps can run simultaneously - -If you launched in Supervised mode, you will see an approval prompt. Review the steps, then click **Approve** to start execution or **Edit** to adjust the plan. - -If the plan looks too broad or too narrow, cancel and rewrite the goal with more detail. The planner responds well to acceptance criteria and explicit constraints. - ---- - -## 3. Monitor Execution - -Once approved, workers begin executing the plan steps. Navigate to the **Mission Run** panel to monitor progress. - -- **Step list** — shows each step with its current status (queued, running, completed, failed) -- **Live terminal** — click any running step to see its agent's terminal output in real time -- **Artifacts** — completed steps surface artifacts (code changes, test results, summaries) in the Artifacts tab - -### Step detail panel - -Click any step in the step list to open its detail panel. The panel shows: - -- **Agent terminal** — live-streaming output from the worker agent -- **File changes** — a diff view of files modified by this step -- **Tool calls** — a log of every ADE CLI tool the agent invoked, with parameters and results -- **Timing** — start time, elapsed duration, and token usage - -If a step is running in parallel with others, each step has its own independent terminal view. Switch between running steps by clicking their rows in the step list. - ---- - -## 4. Handle Interventions - -If a worker gets stuck, it creates an **Intervention** and pauses. Common reasons: - -| Intervention | What to do | -|---|---| -| `approval_required` | Review the output and approve or reject | -| `manual_input` | Provide the answer the agent needs | -| `conflict` | Resolve the merge conflict, then resume | -| `failed_step` | Review the error, fix if needed, then retry or skip | - -Navigate to the intervention banner at the top of the Mission Run panel, provide your input, and click **Resume**. - ---- - -## 5. View Results - -When all steps complete and validation passes, the mission moves to **Completed** status. - -Review the results: -- **Artifacts tab** — all proof artifacts (screenshots, test reports, summaries) collected during execution -- **Plan tab** — the final plan with per-step outcomes -- **Chat tab** — the full conversation history between the coordinator and workers -- **Cost summary** — total tokens and estimated cost for the mission - -### Linking results to PRs - -After a mission completes, you can create a PR directly from the mission's result lane. The PR description is pre-filled with the mission summary, step outcomes, and links to proof artifacts. Reviewers see exactly what the AI did and what evidence supports it. - -To export the full mission audit bundle: **Mission detail → Actions → Export audit bundle**. The bundle includes the plan, all step transcripts, artifacts, cost breakdown, and timing data. - ---- - -## Next Steps - - - - Understand the full mission lifecycle, statuses, and intervention types. - - - Trigger missions automatically from git events or schedules. - - diff --git a/guides/multi-agent-setup.mdx b/guides/multi-agent-setup.mdx index 25ca800dd..f095af16c 100644 --- a/guides/multi-agent-setup.mdx +++ b/guides/multi-agent-setup.mdx @@ -50,9 +50,6 @@ Launch an agent in each lane. There are several ways to do this: Ask the CTO to delegate work. It will create lanes and spawn workers automatically based on your instructions. - - Create a mission with a plan that has parallel steps. The mission coordinator spawns workers into separate lanes and manages them. - Set up automation rules that spawn agents in response to triggers. Each rule can target a specific lane or create a new one. @@ -86,7 +83,6 @@ While agents run, you can inspect their work from several views: | **Lane Terminals** | Live terminal output for each agent session | | **Agent Chat** | The agent's reasoning and tool calls in chat format | | **History** | Timeline of all events across all lanes | -| **Missions > Run** | Step-by-step progress if agents were launched via a mission | Switch between lanes in the Lanes panel to focus on a specific agent, or use the Workspace Graph for the high-level view. @@ -121,7 +117,7 @@ When multiple agents modify overlapping files, ADE detects the conflict risk: As agents complete their work: - Each lane has its changes committed and ready for PR -- Mission artifacts (if applicable) are collected in the Artifacts tab +- Proof artifacts and logs are attached to the lane, PR, or automation run that produced them - The CTO can review results across all lanes and post summaries Open PRs from each lane through the lane context menu or the PR panel. @@ -134,7 +130,7 @@ Open PRs from each lane through the lane context menu or the PR panel. |---|---| | 1-3 | Fine for any machine. Monitor via Lanes panel. | | 4-8 | Use the Workspace Graph for overview. Set budget caps per agent. | -| 8+ | Use missions with parallelism caps. Monitor total token spend in Settings > Usage. | +| 8+ | Split work across named lanes, cap concurrent agents, and monitor total token spend in Settings > Usage. | --- diff --git a/guides/stacked-prs.mdx b/guides/stacked-prs.mdx index c90f2b36d..fa1288459 100644 --- a/guides/stacked-prs.mdx +++ b/guides/stacked-prs.mdx @@ -21,7 +21,7 @@ Start by creating a lane for the first PR in your stack. Click **New Lane**. Set the branch name (e.g., `feature/auth-models`) and select `main` as the base branch. - Switch to the new lane and make your changes — manually, with an agent, or via a mission. + Switch to the new lane and make your changes manually or with an agent. diff --git a/index.mdx b/index.mdx index a29d590cf..3abc0a252 100644 --- a/index.mdx +++ b/index.mdx @@ -1,6 +1,6 @@ --- title: "ADE — Agentic Development Environment" -description: "Orchestrate parallel AI coding agents from a unified desktop control plane. Lanes, missions, context packs, and conflict detection — all local-first." +description: "Orchestrate parallel AI coding agents from a unified desktop control plane. Lanes, worker runs, context packs, and conflict detection — all local-first." --- @@ -8,7 +8,7 @@ description: "Orchestrate parallel AI coding agents from a unified desktop contr New to ADE? Start here. Learn what it does, install the app, and set up your first project in 5 minutes. - Explore every tab — Run, Lanes, Chat, Files, Graph, PRs, Missions, CTO, and more. + Explore every tab — Run, Lanes, Chat, Files, Graph, PRs, Workers, CTO, and more. AI providers, integrations, permissions, budgets, and project configuration. @@ -40,7 +40,7 @@ AI coding agents are fast individually. Running them **in parallel** is where th -**ADE solves this** with isolated lanes, durable context packs, structured missions, a coordinator agent (CTO), and a workspace-wide view of branch state and merge risk. +**ADE solves this** with isolated lanes, durable context packs, structured worker runs, a coordinator agent (CTO), and a workspace-wide view of branch state and merge risk. --- @@ -58,13 +58,13 @@ AI coding agents are fast individually. Running them **in parallel** is where th [Explore CTO →](/cto/overview) - + Multi-step execution with planning, approval, validation, cost tracking, and full audit trails. - [Explore Missions →](/missions/overview) + [Explore Workers →](/cto/workers) - Trigger-driven rules that launch agent sessions or missions in response to git events, schedules, and webhooks. + Trigger-driven rules that launch agent sessions or worker runs in response to git events, schedules, and webhooks. [Explore Automations →](/automations/overview) @@ -74,7 +74,7 @@ AI coding agents are fast individually. Running them **in parallel** is where th [Explore Graph →](/tools/workspace-graph) - Agents capture screenshots, short recordings, and ingested browser traces through the `ade proof` CLI. ADE normalizes and routes each artifact to its mission, chat session, PR, or Linear issue. + Agents capture screenshots, short recordings, and ingested browser traces through the `ade proof` CLI. ADE normalizes and routes each artifact to its worker run, chat session, PR, or Linear issue. [Explore Proof →](/computer-use/overview) diff --git a/introduction.mdx b/introduction.mdx index 45f1d8354..272e9a19e 100644 --- a/introduction.mdx +++ b/introduction.mdx @@ -15,7 +15,7 @@ You decide the work. ADE coordinates the execution. Take the shortest path from install to your first working agent. - Learn how ADE thinks about lanes, missions, the CTO agent, and packs. + Learn how ADE thinks about lanes, worker runs, the CTO agent, and packs. Explore the product by capability instead of digging through one long page. @@ -34,7 +34,7 @@ AI coding agents are fast. Parallel AI work is where things break down: - **Oversight is fragmented**: sessions, branches, costs, and artifacts live in separate tools. - **Execution gets noisy**: you can launch work, but it is hard to track quality, ownership, and state. -ADE addresses that with isolated lanes, durable context packs, missions, a coordinator agent, and a workspace-wide view of branch state. +ADE addresses that with isolated lanes, durable context packs, worker runs, a coordinator agent, and a workspace-wide view of branch state. ## Start with the product map @@ -45,7 +45,7 @@ ADE addresses that with isolated lanes, durable context packs, missions, a coord ADE's isolated workspaces for parallel branches and agents. - + Structured execution when the work needs planning, validation, or audit history. diff --git a/key-concepts.mdx b/key-concepts.mdx index b0a33d4d2..174fe1ddb 100644 --- a/key-concepts.mdx +++ b/key-concepts.mdx @@ -1,6 +1,6 @@ --- title: "Key Concepts" -description: "A comprehensive reference for every core ADE concept: Lanes, Stacks, Packs, Checkpoints, Sessions, the CTO, Missions, Automations, Workers, ADE CLI, the Job Engine, Conflicts, and Artifacts." +description: "A comprehensive reference for every core ADE concept: Lanes, Stacks, Packs, Checkpoints, Sessions, the CTO, Workers, Automations, ADE CLI, the Job Engine, Conflicts, and Artifacts." icon: "lightbulb" --- @@ -11,7 +11,7 @@ icon: "lightbulb" Immutable execution snapshot. Terminal session with metadata. Always-on orchestrator agent. - Structured multi-step AI unit. + Structured multi-step AI unit. Trigger-driven execution rule. Scoped sub-agent that writes code. Native action surface for agents. @@ -70,7 +70,7 @@ ADE tracks rebase suggestions per-lane. When an upstream branch changes, a compa ## Runtime context -ADE keeps runtime context local to the work being done. Agents receive lane/session state, selected artifacts, mission details, and explicit user instructions. When they need product or architecture knowledge, they inspect the repo directly instead of relying on generated PRD or architecture summaries. +ADE keeps runtime context local to the work being done. Agents receive lane/session state, selected artifacts, worker run details, and explicit user instructions. When they need product or architecture knowledge, they inspect the repo directly instead of relying on generated PRD or architecture summaries. ADE also stores some compatibility artifacts under `.ade/artifacts/packs/` for audit and replay: @@ -79,10 +79,10 @@ ADE also stores some compatibility artifacts under `.ade/artifacts/packs/` for a A resolution-context pack created when a conflict is detected between two lanes. Contains the conflicting file regions, each lane's intent, and suggested resolution strategies. Injected into the resolver agent's context. - A versioned coding plan generated during a Mission's Planning phase. Captures the mission's goal, steps, assumptions, risks, and executor assignments. Serves as the authoritative reference for all workers during execution. + A versioned coding plan generated during a Worker run's Planning phase. Captures the worker run's goal, steps, assumptions, risks, and executor assignments. Serves as the authoritative reference for all workers during execution. - - A deterministic snapshot of a mission at a specific point in time — used for resume, audit, and replay. Contains the full mission state: plan, completed steps, artifacts, interventions, and cost data. + + A deterministic snapshot of a worker run at a specific point in time — used for resume, audit, and replay. Contains the full worker run state: plan, completed steps, artifacts, interventions, and cost data. @@ -96,7 +96,7 @@ A **Checkpoint** is an immutable snapshot created at session or commit boundarie ## Session -A **Session** is a terminal session with metadata attached — full transcript, timing, agent attribution, file delta computation, and mission/step linkage. Every terminal window ADE opens is recorded. The History view groups sessions by lane, mission, and date. +A **Session** is a terminal session with metadata attached — full transcript, timing, agent attribution, file delta computation, and worker run/step linkage. Every terminal window ADE opens is recorded. The History view groups sessions by lane, worker run, and date. --- @@ -107,9 +107,9 @@ The **CTO** (Chief Technical Officer agent) is ADE's always-on, project-aware AI The CTO can: - **Spawn Worker agents** in specific lanes to handle delegated tasks - **Sync with Linear** — read issues, update status, create new issues when problems are discovered -- **Manage budgets** — track per-agent and per-mission token spend, enforce limits +- **Manage budgets** — track per-agent and per-worker run token spend, enforce limits - **Surface risks** — proactively flag conflict risks, stale lanes, and budget overruns -- **Conduct retrospectives** — periodically review what missions succeeded and what failed +- **Conduct retrospectives** — periodically review what worker runs succeeded and what failed The CTO is configured from the **CTO** tab (model, budget limit, Linear workspace, worker-spawning policy). Related global preferences — per-role models, monthly caps, Linear integration — live under **Settings → AI**, **Settings → Usage**, and **Settings → Integrations**. @@ -119,15 +119,15 @@ If you are a solo developer, think of the CTO as your always-on senior engineer --- -## Mission +## Worker run -A **Mission** is a structured AI execution unit for complex, multi-step work. Missions provide accountability, cost tracking, and a formal lifecycle that ad-hoc chat sessions do not. +A **Worker run** is a structured AI execution unit for complex, multi-step work. Workers provide accountability, cost tracking, and a formal lifecycle that ad-hoc chat sessions do not. -Missions are visible in every build, but **creation and live runs are paused in packaged releases** until the orchestration flow is production-ready. Dev and internal builds run missions end-to-end. The Automations tab mirrors this: the mission execution surface is shown but disabled in packaged builds. +Workers are visible in every build, but **creation and live runs are paused in packaged releases** until the orchestration flow is production-ready. Dev and internal builds run worker runs end-to-end. The Automations tab mirrors this: the worker run execution surface is shown but disabled in packaged builds. -### Mission Lifecycle +### Worker run Lifecycle ``` Queued → Planning → [Approval] → Execution → Validation → Completed @@ -137,20 +137,20 @@ Queued → Planning → [Approval] → Execution → Validation → Completed - A planner agent (Claude CLI or Codex CLI) reads the mission goal and generates a structured **Plan** — a JSON document with steps, executor hints, lane claims, dependencies, and a join policy. The planner also asks clarifying questions if the goal is ambiguous. + A planner agent (Claude CLI or Codex CLI) reads the worker run goal and generates a structured **Plan** — a JSON document with steps, executor hints, lane claims, dependencies, and a join policy. The planner also asks clarifying questions if the goal is ambiguous. - If the mission requires human approval before execution, ADE pauses here and presents the plan for review. You can accept, edit, or reject the plan. If `launchMode` is `autopilot`, this phase is skipped. + If the worker run requires human approval before execution, ADE pauses here and presents the plan for review. You can accept, edit, or reject the plan. If `launchMode` is `autopilot`, this phase is skipped. - Worker agents execute the plan's steps. Parallel steps run simultaneously in separate lane worktrees. The Mission coordinator monitors progress, handles failures, and retries steps according to the plan's retry policy. + Worker agents execute the plan's steps. Parallel steps run simultaneously in separate lane worktrees. The Worker run coordinator monitors progress, handles failures, and retries steps according to the plan's retry policy. - A dedicated validator agent (or self-validation, depending on the phase profile) reviews the completed work against the mission's goal and acceptance criteria. Computer-use proof may be required (screenshots, browser verification). + A dedicated validator agent (or self-validation, depending on the phase profile) reviews the completed work against the worker run's goal and acceptance criteria. Computer-use proof may be required (screenshots, browser verification). -### Mission Statuses +### Worker run Statuses | Status | Meaning | |---|---| @@ -164,17 +164,17 @@ Queued → Planning → [Approval] → Execution → Validation → Completed ### Interventions -When a mission cannot proceed without human input, it creates an **Intervention** and pauses. Intervention types include: +When a worker run cannot proceed without human input, it creates an **Intervention** and pauses. Intervention types include: - `approval_required` — a phase requires explicit sign-off - `phase_approval` — a phase boundary gate requires approval before advancing - `manual_input` — the agent needs an answer it cannot infer - `conflict` — a merge conflict blocks a step - `failed_step` — a step failed and automatic retry was not successful -- `budget_limit_reached` — the mission has hit its token or cost cap +- `budget_limit_reached` — the worker run has hit its token or cost cap - `orchestrator_escalation` — the coordinator detected an unrecoverable error - `provider_unreachable` — the AI provider is unavailable -You resolve interventions from the Mission's Run panel by providing an answer, accepting defaults, or canceling the run. +You resolve interventions from the Worker run's Run panel by providing an answer, accepting defaults, or canceling the run. --- @@ -206,16 +206,16 @@ An **Automation** is a trigger → execution-surface rule that runs in response Each rule chooses one execution surface: - **agent-session**: creates an automation-only chat thread with AI messages and tool events. -- **mission**: launches a full mission with planner, validation gates, intervention handling, and worker artifacts. -- **built-in-task**: runs a predefined ADE task without full mission semantics. +- **worker run**: launches a full worker run with planner, validation gates, intervention handling, and worker artifacts. +- **built-in-task**: runs a predefined ADE task without full worker run semantics. -Agent-session outputs appear in **Automations → History**. Mission outputs appear in **Missions**. +Agent-session outputs appear in **Automations → History**. Worker run outputs appear in **Workers**. --- ## Worker / Employee Agent -A **Worker** (also called an **Employee Agent**) is a sub-agent spawned by the CTO or by a Mission to handle a specific, scoped task. Workers are the agents that actually write code. +A **Worker** (also called an **Employee Agent**) is a sub-agent spawned by the CTO or by a Worker run to handle a specific, scoped task. Workers are the agents that actually write code. Worker characteristics: - Run in a **specific lane** with a scoped working directory @@ -224,10 +224,10 @@ Worker characteristics: - Are attributed in session metadata for full traceability - Can be `claude_cli`, `codex_cli`, or `unified` (in-process API) executor kinds -The Mission coordinator manages worker lifecycle: spawning, monitoring heartbeats, retrying failed workers, and collecting their outputs. +The Worker run coordinator manages worker lifecycle: spawning, monitoring heartbeats, retrying failed workers, and collecting their outputs. -ADE supports parallel workers — multiple workers executing different plan steps simultaneously in different lanes. The `parallelismCap` field in the mission plan limits how many workers run at once. +ADE supports parallel workers — multiple workers executing different plan steps simultaneously in different lanes. The `parallelismCap` field in the worker run plan limits how many workers run at once. --- @@ -254,15 +254,15 @@ When an AI agent is running inside ADE (whether via Claude CLI, Codex CLI, or th Read, write, and refresh context packs. Agents can update their own lane pack with new context they have discovered. - - Create missions, update step status, add artifacts, create interventions, and report progress back to the Mission coordinator. + + Create worker runs, update step status, add artifacts, create interventions, and report progress back to the Worker run coordinator. - Route screenshots, browser traces, video recordings, and console logs to their owners (missions, PRs, Linear issues, chat sessions). + Route screenshots, browser traces, video recordings, and console logs to their owners (worker runs, PRs, Linear issues, chat sessions). -Sessions are authenticated by role: `orchestrator`, `agent`, `external`, or `evaluator`. Coordinator-level tools (spawning agents, creating missions) are only available to `orchestrator`-role callers. +Sessions are authenticated by role: `orchestrator`, `agent`, `external`, or `evaluator`. Coordinator-level tools (spawning agents, creating worker runs) are only available to `orchestrator`-role callers. --- @@ -340,7 +340,7 @@ A **Proof** (also called an **Artifact**) is evidence produced during AI-driven | `summary` | A human-readable summary of completed work | | `pr` | A link to an opened or merged pull request | | `patch` | A raw git patch file | -| `plan` | The generated mission plan document | +| `plan` | The generated worker run plan document | ### Proof Capture Flow @@ -348,9 +348,9 @@ Agents capture intentional evidence by calling the `ade proof` CLI — screensho 1. The agent runs `ade proof screenshot`, `ade proof record`, or `ade proof ingest` from a terminal inside its lane. 2. ADE's artifact broker in the main process normalizes the input into a standard `ComputerUseArtifact` record with kind, owner, and metadata. -3. The artifact is **routed** to its owner — a mission step, a PR, a Linear issue, or a chat session — based on the flags passed to the CLI. +3. The artifact is **routed** to its owner — a worker run step, a PR, a Linear issue, or a chat session — based on the flags passed to the CLI. 4. The file is stored under `.ade/artifacts/computer-use/` and linked in the database. -5. It appears in the **Mission Artifacts tab**, the PR attachments panel, the chat session artifact panel, or the Linear issue sidebar, depending on routing. +5. It appears in the **Worker run Artifacts tab**, the PR attachments panel, the chat session artifact panel, or the Linear issue sidebar, depending on routing. --- @@ -369,12 +369,12 @@ Project │ ├── Stacks (chains of lanes) │ -├── Missions (1..n) +├── Workers (1..n) │ ├── Plan (generated by Planner) │ ├── Steps → Workers → Sessions │ ├── Interventions (human touchpoints) │ ├── Artifacts (proof, summaries, PRs) -│ └── Mission Pack (audit snapshot) +│ └── Worker run Pack (audit snapshot) │ ├── CTO Agent │ ├── Project Memory @@ -382,7 +382,7 @@ Project │ └── Linear sync │ ├── Automations -│ └── Triggers → Missions or direct agent runs +│ └── Triggers → Workers or direct agent runs │ └── Job Engine ├── Conflict prediction @@ -406,20 +406,20 @@ Project | **Checkpoint** | An immutable snapshot of lane state at a session or commit boundary. | | **Session** | A PTY terminal session with metadata, transcript, and delta tracking. | | **CTO** | Always-on project-aware AI agent at the top of the agent org chart. | -| **Mission** | A structured multi-step AI execution unit with full lifecycle and audit trail. | -| **Worker / Employee Agent** | A sub-agent spawned by the CTO or a Mission to handle a specific task. | +| **Worker run** | A structured multi-step AI execution unit with full lifecycle and audit trail. | +| **Worker / Employee Agent** | A sub-agent spawned by the CTO or a Worker run to handle a specific task. | | **Automation** | A trigger → executor rule that fires an agent in response to an event. | -| **Automation execution surface** | `agent-session`, `mission`, or `built-in-task`; each determines where results are stored and how much workflow structure is available. | -| **Intervention** | A mission pause point requiring human input to continue. | +| **Automation execution surface** | `agent-session`, `worker run`, or `built-in-task`; each determines where results are stored and how much workflow structure is available. | +| **Intervention** | A worker run pause point requiring human input to continue. | | **ADE CLI** | The native action surface for agents — a local `ade` binary plus a JSON-RPC 2.0 server exposed by the desktop app at `.ade/ade.sock`. | | **Job Engine** | ADE's background scheduler for pack refresh, conflict prediction, and automation execution. | | **PR Convergence** | ADE's automated loop for resolving PR issues (review comments, CI failures) through AI-driven rounds until the PR is ready to merge. | | **Path to Merge** | The dedicated convergence workspace tab inside each PR detail view. | | **Conflict** | A predicted or actual merge conflict between two lanes. | | **Artifact / Proof** | Evidence captured via `ade proof` (screenshots, recordings, browser traces, console logs, test reports). | -| **Plan** | The structured execution document generated by a Mission's planner agent. | -| **Phase** | A stage in a Mission's lifecycle (Planning, Execution, Validation, etc.). | -| **Phase Profile** | A reusable configuration of phases and their settings, applied to missions at launch. | +| **Plan** | The structured execution document generated by a Worker run's planner agent. | +| **Phase** | A stage in a Worker run's lifecycle (Planning, Execution, Validation, etc.). | +| **Phase Profile** | A reusable configuration of phases and their settings, applied to worker runs at launch. | | **Phase Card** | The configuration unit for a single phase: model, budget, validation gate, approval requirement. | | **baseRef** | The project's primary integration branch (e.g., `main`). Configured in `ade.yaml`. | | **`.ade/`** | The directory ADE creates at the repo root to store its metadata, worktrees, and artifacts. | @@ -432,7 +432,7 @@ Project Everything about lane creation, templates, environment isolation, and proxy routing. - + Phase profiles, budget management, interventions, and audit log export. diff --git a/missions/budget.mdx b/missions/budget.mdx deleted file mode 100644 index 4fbec0e77..000000000 --- a/missions/budget.mdx +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: "Budget and permissions" -description: "Configure budget caps, per-provider permission modes, proof capture, mission persistence, and resolve common mission problems." -icon: "coins" ---- - -## Budget caps - -Set a **Budget cap** in tokens or USD when you create a mission. This is the combined ceiling across all workers, not a per-worker limit. - -When the mission reaches **80% of the cap**, ADE raises a `budget_limit_reached` intervention before work stops. You have three choices: - -- **Approve more budget** — raise the cap and continue -- **Reduce scope** — cancel remaining workers, accept partial results -- **Stop cleanly** — the orchestrator wraps up current work and completes with what is ready - -The Plan tab's cost panel shows per-worker token consumption, projected total at the current burn rate, and USD estimates using the configured model's pricing. - -If you don't set a cap, ADE applies the default from **Settings → Missions → Default budget cap**. - ---- - -## Per-provider permission modes - -Each provider carries its own permission mode. The mission creation dialog (and the Worker Permissions editor inside it) exposes one row per provider. - -| Provider | Default mode | What that means | -|---|---|---| -| Claude | `full-auto` | Standard CLI permissions; agents run without per-step approval | -| Codex | `default` | Workspace-write sandbox, approval policy on-request | -| OpenCode | `full-auto` | OpenCode's standard permission preset | - -### Codex sandbox is derived, not chosen separately - -The Codex permission mode decides the sandbox: - -- `full-auto` → `danger-full-access` (no sandbox) -- `plan` → `read-only` -- anything else (including `default` and `edit`) → `workspace-write` - -There is no separate Codex sandbox dropdown in the Worker Permissions editor anymore — pick the mode and the sandbox follows. Choose `config-toml` if you want Codex to honor its own `~/.codex/config.toml` instead of ADE flags. - -See [AI Providers](/configuration/ai-providers) for the full permission option reference shared across Chat and Missions. - ---- - -## Tool palette - -Workers reach ADE through the `ade` CLI. The mission creation form exposes a tool palette (repo, git, github, linear, mission, tests) that maps to the ADE RPC domains each worker may call. Tools outside the palette are rejected at the CLI boundary for that worker. - -## Proof capture - -Workers decide when a step deserves proof and call the `ade proof` CLI directly — screenshot, short recording, or ingested browser trace. There is no mission-wide policy chip; proof is per-step and intentional. - -Captured proofs link to the mission automatically and land in the **Artifacts** tab. See [Proof Overview](/computer-use/overview) for the capture flow. - -## Writable paths - -By default, workers can only write inside their assigned lane's worktree. Extend this with explicit `writablePaths` entries in the mission permission config — useful when a worker needs to touch shared fixtures or generated files outside its primary lane. - ---- - -## Persistence and resume - -Mission state is durable. Phases, worker logs, artifacts, and interventions live in ADE's local SQLite database and the filesystem, and a **Mission Pack** snapshot is written at every phase boundary. - -If ADE crashes mid-mission: - -1. Restart the app. -2. Open Missions — the interrupted run shows `in_progress` or `intervention_required`. -3. ADE reloads from the last Mission Pack snapshot. -4. Workers that were mid-task resume where they can. Anything that cannot be recovered is marked failed and queued as an intervention. - ---- - -## Troubleshooting - - - - Open the Plan tab. If an intervention is open, the worker is waiting for your response. If not, open the Chat tab and ask the orchestrator for a status update. If the worker is genuinely hung, click its card and choose **Restart worker** to resume from the last checkpoint; **Skip task** marks the step skipped and lets the mission continue. - - - Open the Plan tab's cost panel to find the most expensive workers. You can cancel one worker individually while leaving others running. Approve more budget only when the remaining work is scoped and worth the cost. - - - A `failed_step` intervention fires with a test report artifact. Open the artifact, tell the orchestrator what failed in the Chat tab, and let it spawn a follow-up worker scoped to the failing tests. If failures are expected (tests for features not yet built), dismiss the intervention and mark validation skipped. - - - The approval gate is a hard stop. If you closed the detail view, reopen it from the Missions list — the plan review panel is at the top of the Plan tab's activity feed. - - - Click **Edit Planning Prompt** in the plan review UI, rewrite the prompt to clarify scope or excluded approaches, and click **Re-plan**. Repeat until the plan matches, then approve. - - - Common causes: a selected lane is held by another mission, the configured model is not authenticated or is rate-limited, proof-capture prerequisites are missing on the host, or the phase profile has a config error. Each preflight error states the specific gap; fix it and retry. - - - You are on a packaged (production) build. Mission creation and live runs are paused in production until the orchestration flow is production-ready. Dev and internal builds run Missions end-to-end. See the Missions overview for what the shipping version will do. - - diff --git a/missions/creating.mdx b/missions/creating.mdx deleted file mode 100644 index 0c86dfd9a..000000000 --- a/missions/creating.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: "Creating Missions" -description: "Start a mission from the Missions tab, a lane, the CTO chat, or an existing chat session, then navigate the detail view while it runs." -icon: "plus" ---- - - -Missions are gated off in packaged app releases. In a production build the Missions tab shows a "Coming soon" preview and the New Mission flow is disabled. This page documents the live flow as it exists in dev and internal builds, so teams can plan for the shipping version. The Automations tab behaves the same way: templates whose execution is a Mission show a "Coming soon" chip in packaged builds. - - -## Where a mission can start - - - - - - Click **Missions** in the left sidebar. - - - Press **+ New Mission** in the top-right of the page. - - - Fill the creation dialog: - - - **Title** — short name shown in the list and history - - **Description** — the goal; this is the prompt the planner sees - - **Lane(s)** — one or more lanes the mission can work in; workers run inside each lane's worktree - - **Orchestrator model** — the model that plans and coordinates - - **Worker permissions** — per-provider permission mode (see below) - - **Budget cap** — optional ceiling; mission pauses before the cap is hit, not after - - - ADE checks that selected lanes are available, the configured model is reachable, the permission config is valid, and any required proof-capture prerequisites exist on the host. Blockers surface here, before planning starts. - - - Click **Start Mission**. The run enters **Planning** and the detail view opens. - - - - - Open a lane and click **Start Mission** in the lane toolbar. The creation dialog opens pre-filled with the lane's context and tool palette. - - - Tell the CTO "start a mission for this." The CTO drafts the mission, fills the description from the conversation, and routes it through the same preflight dialog. - - - In any chat session, say "start a mission for this task." ADE transitions the current context into a new mission, carrying the history forward as seed context for the planner. - - - ---- - -## Worker permissions - -Workers can use different providers (Claude, Codex, OpenCode) and the permission mode applies per provider. The defaults ship with: - -- **Claude** — `full-auto` -- **Codex** — `default` (workspace-write sandbox, approval on-request) -- **OpenCode** — `full-auto` - -The Codex mode picks its sandbox automatically: `full-auto` uses `danger-full-access`, `plan` uses `read-only`, and every other preset uses `workspace-write`. The separate Codex sandbox dropdown that used to live below each provider row has been removed — the editor now derives it from the permission mode. - -See [AI Providers](/configuration/ai-providers) for what each mode allows. - ---- - -## The Mission detail view - -Opening a running or completed mission surfaces four tabs: - - - - Live control room. Shows the selected strategy, per-worker cards (assigned lane, status, elapsed time, running token count), a phase progress indicator, the activity feed, and any open interventions. Intervention cards highlight in amber — click to respond. - - - Talk to the mission's orchestrator while it runs. Ask why it chose a strategy, drop in new context mid-run, redirect a worker that is going off track, or request a status summary. Noise from worker streams is filtered — only meaningful signals surface here. - - - Everything workers produced: patches, PRs, test reports, proof captures (screenshots, recordings, browser traces), generated plan snapshot, orchestrator summary, and inline notes. Any artifact can be promoted to a lane pack, attached to a PR, or linked to a Linear issue. - - - Immutable timeline of every planning step, tool call, decision, worker message, and phase boundary. The History tab is the source of truth for audit and debugging, and can be filtered by worker, event type, or phase. - - - ---- - -## Tips for a good first mission - -- Keep the first run narrow — one feature, one lane — before scaling up to multi-lane missions. -- Leave the budget cap set. It is there to pause the run, not to cancel it. -- Treat the **Approval gate** as a checklist: lanes, permissions, and strategy should all look right before you approve the plan. -- Edit the Planning Prompt Preview instead of re-running with the same description when the first plan misses intent. diff --git a/missions/overview.mdx b/missions/overview.mdx deleted file mode 100644 index 4b72f1ec0..000000000 --- a/missions/overview.mdx +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: "Missions Overview" -description: "Multi-step orchestration for work that is bigger than one chat thread: planning, lane orchestration, human checkpoints, and closeout captured as one run." -icon: "bullseye" ---- - -## What a Mission is - -A **Mission** is ADE's orchestration layer for work that outgrows a single chat thread. Where Agent Chat is a fast loop in one lane, a Mission turns a bigger goal into phases, delegates execution to workers running in their own lanes, pauses at checkpoints when you or the plan require it, and gathers every log, summary, and artifact into one reviewable record. - - -Missions are visible as a tab in every build so teams can plan around them, but **creation and live runs are paused in production builds** until the orchestration flow is production-ready. Dev and internal builds run Missions end-to-end today. The tab shows a "Coming soon" preview in packaged app releases — plan with it now, automate with it when it ships. - - - - - Turn a larger goal into phases, worker lanes, checkpoints, and proof requirements before any agent starts typing. - - - Coordinate multi-agent work across isolated lanes, then bring results back with reviewable handoffs. - - - Pause when ADE needs operator input, budget approval, or a decision before external side effects happen. - - - Collect logs, summaries, screenshots, and implementation proof into one mission record, attached to the associated lanes and PRs. - - - ---- - -## When a Mission is the right tool - -| Use a Mission when... | Use Chat or an Automation when... | -|---|---| -| Work spans several lanes or files that should not step on each other | A single file or targeted change resolves the task | -| Human approval should gate a destructive or external step | No external side effects, or you will watch the whole run | -| You need a durable audit of plan, decisions, and proof | A transcript in Chat or History is enough | -| A plan should be reviewed and edited before agents spend tokens | The prompt fully describes the work | - -You can escalate a Chat session into a Mission by asking the agent to "start a mission for this." ADE carries the conversation over as seed context for planning. - ---- - -## Phases - -Every Mission moves through an ordered sequence. The boundary between phases is a checkpoint — ADE snapshots state before advancing so the mission can be paused, resumed, or replayed. - - - - The orchestrator reads the goal, picks a strategy, lays out worker assignments and lane routing, and estimates cost and tool needs. You can open the **Planning Prompt Preview** to inspect — and optionally edit — the exact prompt the planner receives. - - - The finished plan is presented before any worker spends tokens. This is a hard stop. Approve, request revisions with feedback, or cancel while nothing has run. - - - Workers run according to the approved plan. The Plan tab surfaces worker status, token consumption, and the live decision feed. You can talk to the orchestrator in the Chat tab at any time without interrupting execution. - - - The mission's validation set (tests, type checks, custom validators) runs against the result. Validation output is captured as artifacts. Failures surface as interventions rather than silently ending the mission. - - - ---- - -## Worker strategies - -Before planning starts, the planner picks how workers should run. The goal is to shorten wall-clock time without creating merge pain between workers. - - - - One worker at a time. Output from step N is available as context for step N+1. Used when steps have tight dependencies. - - - A small, bounded number of independent workers run at once. The planner sets a cap to avoid rate limits and context overlap. - - - Every independent unit starts at the same time in its own lane. Fastest for work that genuinely factors into independent chunks (for example, adding tests for a batch of functions). - - - ---- - -## Where Missions live - -- **Missions tab** — the canonical surface for creating, reviewing, and monitoring missions. Always visible in the sidebar. In packaged builds the tab shows a "Coming soon" preview with the feature summary. -- **Automations** — an automation rule can select `mission` as its execution surface. In packaged builds the mission option is visible but disabled with a "Coming soon" chip, so teams can see how they would wire mission automations and plan around it. -- **Chat** — any chat thread can escalate into a mission; conversation history seeds the planner. - ---- - -## Continue reading - - - - Start a mission from the Missions tab, a lane, the CTO chat, or an existing chat session. - - - How workers run, when missions pause for human input, and how failures are classified. - - - Budget caps, per-provider permission modes, proof capture, persistence, and troubleshooting. - - diff --git a/missions/workers.mdx b/missions/workers.mdx deleted file mode 100644 index ab250f4d6..000000000 --- a/missions/workers.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Workers and interventions" -description: "How mission workers run, when a mission pauses for human input, and how ADE classifies failures." -icon: "users" ---- - -## Ephemeral workers - -Each worker spawned by a mission is ephemeral. It exists only for its assigned tasks, runs inside a lane's worktree, carries a scoped tool palette, has its own budget allocation, and is cleaned up when the mission ends. - -Workers inherit three things from the mission: - -- The **lane** they are assigned to (files, ports, environment) -- The **provider and permission mode** (Claude / Codex / OpenCode) chosen at mission creation -- The **Mission Pack snapshot** at the current phase boundary, used to seed their context - -### Context management - -Workers run against the provider's native context window. When usage approaches the compaction threshold, ADE compacts older messages into a summary and preserves current task state, decisions, and file paths verbatim. This avoids mid-task context overflow without losing what was already done. - -### Recovery - -If a worker crashes or drops its connection, ADE tries to resume from its last known state. Transient errors (rate limits, network blips) retry automatically. If resume fails, the worker's step is marked failed and an intervention is raised. - ---- - -## Interventions - -Beyond the approval gate, missions pause mid-run for decisions that need a human. When an intervention fires, ADE pauses the affected worker (other workers keep going), surfaces the intervention in the Plan tab, and notifies you if ADE is backgrounded. - - - - The worker hit a decision that needs explicit sign-off. Typical triggers: destructive operations, steps outside the configured permission scope, or explicit pauses from a custom tool. - - - The mission is near or at its cap. You can approve more budget, reduce scope, or stop cleanly and accept partial results. - - - A phase boundary is configured to require approval. Review the phase output and decide whether to advance. - - - A step failed and automatic retry did not succeed. Provide new instructions, skip the step, or cancel the mission. - - - The orchestrator has a question it cannot answer from context. Respond in the mission chat or in the intervention card. - - - ---- - -## Error classification - -Every mission error falls into one of three buckets: - -| Classification | Behavior | -|---|---| -| Recoverable | Worker retries with backoff; no human action | -| User action needed | Intervention raised; affected worker waits | -| Fatal | Mission stops; full error report attached as an artifact | - -This keeps transient noise from stalling a run and ensures genuinely blocking errors reach you quickly instead of quietly failing. diff --git a/plans/ade-hub.md b/plans/ade-hub.md index aa445bd59..d6f4c30f8 100644 --- a/plans/ade-hub.md +++ b/plans/ade-hub.md @@ -149,7 +149,7 @@ Screens (TBD, no UI work yet): - **Home**: registered repo count, your contribution stats, agents currently running, recent PRs. - **Browse repos / issues**: filter by language, label, repo, claimed/unclaimed. - **Launch agent**: pick repo(s) or "anything I'm qualified for", budget cap (token / dollar / PR count), claim policy, which CLI to wrap, dry-run vs auto-submit. -- **Running agents**: live list (analogous to ADE Code's running missions), with current step, tokens consumed, current issue. +- **Running agents**: live list with current step, tokens consumed, and current issue. - **Settings**: GitHub OAuth, default CLI, default budgets, banned-repo personal allowlist. Reuses from ADE Code: chat surface, lane/worktree model (each agent run = one lane), PR creation flow, model/sub detection, budget metering. diff --git a/quickstart.mdx b/quickstart.mdx index 9dce5c0d3..5a5738e02 100644 --- a/quickstart.mdx +++ b/quickstart.mdx @@ -19,7 +19,7 @@ icon: "rocket" Fork a task into its own isolated worktree. - Use Agent Chat for a fast task or launch a Mission for structured execution. + Use Agent Chat for a fast task or launch a Worker run for structured execution. Read the concepts page once so the rest of the docs make sense. @@ -56,12 +56,12 @@ Small, easy to review, and exercises the full loop: open a lane → run an agent - Understand every core concept in ADE: Lanes, Packs, Missions, the CTO, Automations, and more. + Understand every core concept in ADE: Lanes, Packs, Workers, the CTO, Automations, and more. Deep dive into lane types, templates, environment isolation, port allocation, and proxy routing. - + Learn how to configure phase profiles, set budget limits, handle interventions, and export audit bundles. diff --git a/scripts/dogfood.sh b/scripts/dogfood.sh index db2ca9003..1841b8dd4 100755 --- a/scripts/dogfood.sh +++ b/scripts/dogfood.sh @@ -5,7 +5,7 @@ # ./scripts/dogfood.sh # run from main repo # ./scripts/dogfood.sh # run code from a lane's worktree # -# Both instances share the same DB (lanes, missions, configs stay in sync). +# Both instances share the same DB (lanes, configs, and runtime state stay in sync). # The dogfood instance uses an isolated ADE CLI socket. The sync host stays enabled # by default and will retry alternate ports if your primary ADE window already # owns the default port, which keeps mobile pairing usable while dogfooding. diff --git a/scripts/perf-launch.mjs b/scripts/perf-launch.mjs index 4f6737802..c9c2aaeca 100755 --- a/scripts/perf-launch.mjs +++ b/scripts/perf-launch.mjs @@ -6,7 +6,7 @@ * * Usage: * scripts/perf-launch.mjs --tab lanes - * scripts/perf-launch.mjs --tab missions --run-id manual + * scripts/perf-launch.mjs --tab work --run-id manual * scripts/perf-launch.mjs --tab boot --no-project * scripts/perf-launch.mjs --route /settings --project "/path/to/repo" */ diff --git a/scripts/seed-missions-large-fixture.mjs b/scripts/seed-missions-large-fixture.mjs deleted file mode 100644 index 7c380af3c..000000000 --- a/scripts/seed-missions-large-fixture.mjs +++ /dev/null @@ -1,883 +0,0 @@ -#!/usr/bin/env node -import { copyFileSync, existsSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { homedir } from "node:os"; -import { randomUUID } from "node:crypto"; -import { DatabaseSync } from "node:sqlite"; - -const args = process.argv.slice(2); -const projectRoot = - args.find((arg) => arg.startsWith("--project="))?.slice("--project=".length) - ?? process.env.ADE_PERF_PASS_DIR - ?? join(homedir(), "Projects", "perf pass"); -const dbPath = join(projectRoot, ".ade", "ade.db"); -const missionId = - args.find((arg) => arg.startsWith("--mission-id="))?.slice("--mission-id=".length) - ?? "perf-large-mission-20260520"; -const runId = - args.find((arg) => arg.startsWith("--run-id="))?.slice("--run-id=".length) - ?? "perf-large-run-20260520"; -const rawStepCountTarget = args.find((arg) => arg.startsWith("--steps="))?.slice("--steps=".length) ?? "144"; -const stepCountTarget = Number(rawStepCountTarget); - -if (!Number.isInteger(stepCountTarget) || stepCountTarget <= 0) { - console.error(`Invalid --steps value: ${rawStepCountTarget}`); - process.exit(2); -} - -if (!existsSync(dbPath)) { - console.error(`ADE DB not found: ${dbPath}`); - process.exit(2); -} - -const backupPath = `${dbPath}.backup-missions-large-${new Date() - .toISOString() - .replace(/[-:]/g, "") - .replace(/\..+$/, "") - .replace("T", "-")}`; -copyFileSync(dbPath, backupPath); - -const db = new DatabaseSync(dbPath, { allowExtension: true }); -const crsqliteExtension = (() => { - const extensionName = - process.platform === "win32" ? "crsqlite.dll" : process.platform === "darwin" ? "crsqlite.dylib" : "crsqlite.so"; - const platformArch = - process.platform === "darwin" && process.arch === "arm64" - ? "darwin-arm64" - : process.platform === "darwin" - ? "darwin-x64" - : process.platform === "win32" - ? "win32-x64" - : "linux-x64"; - const candidate = join(process.cwd(), "apps", "desktop", "vendor", "crsqlite", platformArch, extensionName); - return existsSync(candidate) ? candidate : null; -})(); - -if (crsqliteExtension) { - db.enableLoadExtension(true); - db.loadExtension(crsqliteExtension); - db.enableLoadExtension(false); - db.prepare("select crsql_internal_sync_bit() as sync_bit").get(); -} - -const nowBase = Date.parse("2026-05-20T12:10:00.000Z"); -const iso = (offsetMs = 0) => new Date(nowBase + offsetMs).toISOString(); -const json = (value) => JSON.stringify(value); - -const project = db.prepare("select id from projects order by last_opened_at desc limit 1").get(); -if (!project?.id) throw new Error("No project row found."); -const lane = db - .prepare("select id from lanes where project_id = ? order by created_at asc limit 1") - .get(project.id); -const projectId = project.id; -const laneId = lane?.id ?? null; - -const phaseSpecs = [ - ["planning", "Planning", "Clarify scope, ask blocking questions, and split the work."], - ["product_research", "Product research", "Map personas, critical flows, and launch risks."], - ["architecture", "Architecture", "Design app structure, data contracts, and ownership boundaries."], - ["data_layer", "Data layer", "Build mocked APIs, fixtures, and derived metrics."], - ["experience", "Experience", "Build the primary product surfaces and states."], - ["integration", "Integration", "Consolidate worker output into one result lane."], - ["testing", "Testing", "Run logic, route, and build validation."], - ["validation", "Validation", "Audit against user goals and risk register."], - ["closeout", "Closeout", "Collect final evidence and summarize the result."], -]; - -function buildPhases() { - return phaseSpecs.map(([phaseKey, name, instructions], position) => ({ - id: `perf:${phaseKey}`, - phaseKey, - name, - description: instructions, - instructions, - model: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - budget: {}, - orderingConstraints: { - mustBeFirst: position === 0, - mustBeLast: position === phaseSpecs.length - 1, - mustFollow: position === 0 ? [] : [phaseSpecs[position - 1][0]], - mustPrecede: [], - canLoop: false, - loopTarget: null, - }, - askQuestions: { - enabled: phaseKey === "planning", - requiredBeforeExit: phaseKey === "planning", - maxQuestions: 3, - }, - validationGate: - phaseKey === "closeout" - ? { - tier: "self", - required: true, - criteria: "Final outcome, changed files, validation, and risk notes are present.", - evidenceRequirements: ["final_outcome_summary", "changed_files_summary", "risk_notes"], - } - : phaseKey === "testing" || phaseKey === "validation" || phaseKey === "integration" - ? { - tier: "dedicated", - required: true, - criteria: `${name} output has been independently checked.`, - evidenceRequirements: ["changed_files_summary", "risk_notes"], - } - : { tier: "none", required: false }, - requiresApproval: phaseKey === "planning" || phaseKey === "validation", - isBuiltIn: ["planning", "integration", "testing", "validation", "closeout"].includes(phaseKey), - isCustom: !["planning", "integration", "testing", "validation", "closeout"].includes(phaseKey), - position, - createdAt: iso(-60_000), - updatedAt: iso(-60_000), - })); -} - -function buildRows(phases) { - const stepRows = []; - const attemptRows = []; - const missionStepRows = []; - const stepIdsByPhase = new Map(); - const basePerPhase = Math.max(8, Math.floor(stepCountTarget / phases.length)); - let globalIndex = 0; - - for (const phase of phases) { - const phaseSteps = []; - const count = - phase.phaseKey === "planning" - ? Math.max(10, Math.floor(basePerPhase * 0.75)) - : phase.phaseKey === "closeout" - ? Math.max(8, Math.floor(basePerPhase * 0.55)) - : basePerPhase; - for (let i = 0; i < count; i += 1) { - const missionStepId = randomUUID(); - const stepId = randomUUID(); - const attemptId = randomUUID(); - const stepKey = `${phase.phaseKey}_${String(i + 1).padStart(2, "0")}`; - const startAt = iso(globalIndex * 60_000); - const doneAt = iso(globalIndex * 60_000 + 45_000); - const dependencies = - i === 0 - ? phases[phase.position - 1] - ? (stepIdsByPhase.get(phases[phase.position - 1].phaseKey) ?? []).slice(-3) - : [] - : phaseSteps.slice(Math.max(0, i - 3), i); - - missionStepRows.push({ - id: missionStepId, - mission_id: missionId, - project_id: projectId, - step_index: globalIndex, - title: `${phase.name}: work package ${i + 1}`, - detail: `Synthetic ${phase.name} work package ${i + 1}; created to stress Missions plan, timeline, chat, and artifact views.`, - kind: phase.phaseKey === "planning" ? "planning" : phase.phaseKey === "testing" ? "test" : "code", - lane_id: laneId, - status: "succeeded", - metadata_json: json({ - phaseKey: phase.phaseKey, - phaseName: phase.name, - phasePosition: phase.position, - syntheticFixture: true, - }), - created_at: startAt, - updated_at: doneAt, - started_at: startAt, - completed_at: doneAt, - }); - stepRows.push({ - id: stepId, - run_id: runId, - project_id: projectId, - mission_step_id: missionStepId, - step_key: stepKey, - step_index: globalIndex, - title: `${phase.name}: worker ${i + 1}`, - lane_id: laneId, - status: "succeeded", - join_policy: "all_success", - quorum_count: null, - dependency_step_ids_json: json(dependencies), - retry_limit: 1, - retry_count: 0, - last_attempt_id: attemptId, - policy_json: json({ claimScopes: [`${phase.phaseKey}/**`] }), - metadata_json: json({ - phaseKey: phase.phaseKey, - phaseName: phase.name, - phasePosition: phase.position, - stepType: phase.phaseKey === "planning" ? "planning" : phase.phaseKey, - phaseModel: phase.model, - phaseInstructions: phase.instructions, - phaseValidation: phase.validationGate, - validationState: "pass", - lastValidationReport: { - verdict: "pass", - summary: `${phase.name} worker ${i + 1} passed synthetic validation with no unresolved risk findings.`, - findings: [], - }, - lastResultReport: { - summary: `${phase.name} worker ${i + 1} completed a bounded slice of the launch-control platform.`, - }, - syntheticFixture: true, - }), - created_at: startAt, - updated_at: doneAt, - started_at: startAt, - completed_at: doneAt, - }); - attemptRows.push({ - id: attemptId, - run_id: runId, - step_id: stepId, - project_id: projectId, - attempt_number: 1, - status: "succeeded", - executor_kind: i % 5 === 0 ? "manual" : "codex", - executor_session_id: `synthetic-session-${stepKey}`, - tracked_session_enforced: 0, - context_profile: "orchestrator_deterministic_v1", - context_snapshot_id: null, - error_class: "none", - error_message: null, - retry_backoff_ms: 0, - result_envelope_json: json({ - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: `${phase.name} worker ${i + 1} completed synthetic output.`, - outputs: { filesChanged: [`src/${phase.phaseKey}/package-${i + 1}.ts`] }, - warnings: [], - sessionId: `synthetic-session-${stepKey}`, - trackedSession: false, - }), - metadata_json: json({ - syntheticFixture: true, - model: "openai/gpt-5.5", - workerCompletedAt: doneAt, - }), - created_at: startAt, - started_at: startAt, - completed_at: doneAt, - }); - phaseSteps.push(stepId); - globalIndex += 1; - } - stepIdsByPhase.set(phase.phaseKey, phaseSteps); - } - - return { stepRows, attemptRows, missionStepRows }; -} - -function runStatement(sql, rows, map) { - const stmt = db.prepare(sql); - for (const row of rows) stmt.run(...map(row)); -} - -const phases = buildPhases(); -const { stepRows, attemptRows, missionStepRows } = buildRows(phases); -const phaseRuntime = { - currentPhaseKey: "closeout", - currentPhaseName: "Closeout", - currentPhaseModel: phases.at(-1).model, - currentPhaseInstructions: phases.at(-1).instructions, - currentPhaseValidation: phases.at(-1).validationGate, - currentPhaseBudget: {}, - transitionedAt: iso(7_200_000), - transitions: phases - .slice() - .reverse() - .map((phase) => { - const previous = phases[phase.position - 1] ?? null; - return { - fromPhaseKey: previous?.phaseKey ?? null, - fromPhaseName: previous?.name ?? null, - toPhaseKey: phase.phaseKey, - toPhaseName: phase.name, - at: iso(phase.position * 900_000), - reason: phase.position === 0 ? "run_initialized" : "synthetic_large_fixture_phase_complete", - }; - }), - phaseBudgets: Object.fromEntries( - phases.map((phase) => [ - phase.phaseKey, - { - enteredAt: iso(phase.position * 900_000), - usedTokens: 12_000 + phase.position * 1500, - usedCostUsd: 0.01 * phase.position, - }, - ]), - ), -}; -const phaseConfiguration = { - profileId: "synthetic:factory-scale", - phaseKeys: phases.map((phase) => phase.phaseKey), - phaseCount: phases.length, - phases, -}; -const missionMetadata = { - source: "synthetic_perf_fixture", - version: 1, - fixture: { - kind: "large_missions_read_side", - generatedAt: iso(), - stepCount: stepRows.length, - timelineEventCount: 2200, - chatMessageCount: 720, - }, - launch: { - autostart: false, - runMode: "autopilot", - autopilotExecutor: "codex", - modelConfig: { - orchestratorModel: { provider: "codex", modelId: "openai/gpt-5.5", thinkingLevel: "low" }, - smartBudget: { enabled: true, fiveHourThresholdUsd: 8, weeklyThresholdUsd: 40 }, - }, - }, - phaseConfiguration, - plannerPlan: { - missionSummary: { - title: "Synthetic Atlas launch-control platform", - objective: "Build and validate a multi-surface launch operations app with evidence workflows.", - complexity: "high", - strategy: "phased_parallel", - parallelismCap: 5, - }, - assumptions: [ - "Synthetic fixture only; no agents were launched.", - "Workers represent plausible phase ownership for UI/perf testing.", - ], - risks: [ - "Large timelines can stress rendering.", - "Worker chat history can stress transcript hydration.", - ], - }, -}; -const runMetadata = { - missionGoal: "Synthetic large-scope mission fixture for Missions UI performance and functionality testing.", - missionPrompt: - "Build a launch-control platform with planning questions, custom phases, workers, validation, artifacts, and closeout evidence.", - runMode: "autopilot", - maxParallelWorkers: 5, - planner: { - source: "synthetic_perf_fixture", - stepCount: stepRows.length, - strategy: "phased_parallel", - parallelismCap: 5, - }, - autopilot: { enabled: false, executorKind: "codex", ownerId: "synthetic-fixture", parallelismCap: 5 }, - aiFirst: true, - phaseConfiguration, - phaseOverride: phases, - phaseRuntime, - runNarrative: stepRows - .filter((_, index) => index % 9 === 0) - .map((step) => ({ - stepKey: step.step_key, - summary: `succeeded: ${step.title} completed.`, - at: step.completed_at, - })), - completionDiagnostics: phases.map((phase) => ({ - phase: phase.phaseKey, - code: "phase_succeeded", - message: `Phase "${phase.name}" completed in synthetic fixture.`, - blocking: false, - })), - completionRiskFactors: [], - completionValidation: { canComplete: true, blockers: [], validatedAt: iso(8_000_000) }, - finalizedAt: iso(8_100_000), -}; - -function seed() { - const missionScopedTables = [ - "orchestrator_worker_digests", - "orchestrator_worker_checkpoints", - "orchestrator_artifacts", - "orchestrator_chat_messages", - "orchestrator_chat_threads", - "orchestrator_runs", - "mission_interventions", - "mission_artifacts", - "mission_events", - "mission_steps", - ]; - for (const table of ["orchestrator_runtime_events", "orchestrator_timeline_events", "orchestrator_attempts", "orchestrator_steps"]) { - db.prepare(`delete from ${table} where run_id = ?`).run(runId); - } - for (const table of missionScopedTables) { - db.prepare(`delete from ${table} where mission_id = ?`).run(missionId); - } - db.prepare("delete from missions where id = ?").run(missionId); - db.prepare("delete from orchestrator_runs where id = ?").run(runId); - - db.prepare( - `insert into missions( - id, project_id, lane_id, mission_lane_id, result_lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, queue_claim_token, queue_claimed_at, outcome_summary, - last_error, metadata_json, created_at, updated_at, started_at, completed_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - missionId, - projectId, - laneId, - laneId, - laneId, - "Synthetic Atlas launch-control platform - large Missions UI fixture", - "Synthetic large-scope mission: plan, build, test, validate, and close out a launch-control platform with many workers and artifacts. This row was inserted as a read-side UI/perf fixture and did not launch agents.", - "completed", - "normal", - "local", - null, - null, - null, - `Synthetic fixture completed: ${stepRows.length} work packages, 24 worker channels, 720 chat messages, and 2200 timeline events.`, - null, - json(missionMetadata), - iso(-120_000), - iso(8_100_000), - iso(0), - iso(8_100_000), - null, - ); - db.prepare( - `insert into orchestrator_runs( - id, project_id, mission_id, status, context_profile, scheduler_state, runtime_cursor_json, - last_error, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - runId, - projectId, - missionId, - "succeeded", - "orchestrator_deterministic_v1", - "completed", - json({ syntheticFixture: true, frontier: { terminal: stepRows.length } }), - null, - json(runMetadata), - iso(0), - iso(8_100_000), - iso(0), - iso(8_100_000), - ); - - runStatement( - `insert into mission_steps( - id, mission_id, project_id, step_index, title, detail, kind, lane_id, status, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - missionStepRows, - (row) => [ - row.id, - row.mission_id, - row.project_id, - row.step_index, - row.title, - row.detail, - row.kind, - row.lane_id, - row.status, - row.metadata_json, - row.created_at, - row.updated_at, - row.started_at, - row.completed_at, - ], - ); - runStatement( - `insert into orchestrator_steps( - id, run_id, project_id, mission_step_id, step_key, step_index, title, lane_id, status, - join_policy, quorum_count, dependency_step_ids_json, retry_limit, retry_count, last_attempt_id, - policy_json, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - stepRows, - (row) => [ - row.id, - row.run_id, - row.project_id, - row.mission_step_id, - row.step_key, - row.step_index, - row.title, - row.lane_id, - row.status, - row.join_policy, - row.quorum_count, - row.dependency_step_ids_json, - row.retry_limit, - row.retry_count, - row.last_attempt_id, - row.policy_json, - row.metadata_json, - row.created_at, - row.updated_at, - row.started_at, - row.completed_at, - ], - ); - runStatement( - `insert into orchestrator_attempts( - id, run_id, step_id, project_id, attempt_number, status, executor_kind, executor_session_id, - tracked_session_enforced, context_profile, context_snapshot_id, error_class, error_message, - retry_backoff_ms, result_envelope_json, metadata_json, created_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - attemptRows, - (row) => [ - row.id, - row.run_id, - row.step_id, - row.project_id, - row.attempt_number, - row.status, - row.executor_kind, - row.executor_session_id, - row.tracked_session_enforced, - row.context_profile, - row.context_snapshot_id, - row.error_class, - row.error_message, - row.retry_backoff_ms, - row.result_envelope_json, - row.metadata_json, - row.created_at, - row.started_at, - row.completed_at, - ], - ); - - const eventStmt = db.prepare( - `insert into orchestrator_timeline_events( - id, project_id, run_id, step_id, attempt_id, claim_id, event_type, reason, detail_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (let i = 0; i < 2200; i += 1) { - const step = stepRows[i % stepRows.length]; - const attempt = attemptRows[i % attemptRows.length]; - const type = - i % 11 === 0 - ? "phase_transition" - : i % 7 === 0 - ? "attempt_completed" - : i % 5 === 0 - ? "worker_progress" - : "scheduler_tick"; - eventStmt.run( - randomUUID(), - projectId, - runId, - step.id, - attempt.id, - null, - type, - `synthetic_${type}`, - json({ syntheticFixture: true, index: i, stepKey: step.step_key, summary: `${type} event ${i}` }), - iso(i * 3500), - ); - } - - const runtimeStmt = db.prepare( - `insert into orchestrator_runtime_events( - id, project_id, run_id, step_id, attempt_id, session_id, event_type, event_key, - occurred_at, payload_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (let i = 0; i < 300; i += 1) { - const step = stepRows[i % stepRows.length]; - const attempt = attemptRows[i % attemptRows.length]; - runtimeStmt.run( - randomUUID(), - projectId, - runId, - step.id, - attempt.id, - attempt.executor_session_id, - i % 13 === 0 ? "question" : i % 3 === 0 ? "progress" : "done", - `synthetic:${i}`, - iso(i * 12_000), - json({ syntheticFixture: true, index: i, preview: `Runtime event ${i} for ${step.step_key}` }), - iso(i * 12_000), - ); - } - - const coordinatorThreadId = `mission:${missionId}`; - const workerThreadIds = stepRows - .filter((_, index) => index % 6 === 0) - .map((step) => ({ threadId: `worker:${step.step_key}`, step })); - const threadStmt = db.prepare( - `insert into orchestrator_chat_threads( - id, project_id, mission_id, thread_type, title, run_id, step_id, step_key, attempt_id, - session_id, lane_id, status, unread_count, metadata_json, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - threadStmt.run( - coordinatorThreadId, - projectId, - missionId, - "coordinator", - "Orchestrator", - runId, - null, - null, - null, - `synthetic-coordinator-${runId}`, - laneId, - "closed", - 0, - json({ syntheticFixture: true }), - iso(0), - iso(8_100_000), - ); - for (const { threadId, step } of workerThreadIds) { - const attempt = attemptRows.find((entry) => entry.step_id === step.id); - threadStmt.run( - threadId, - projectId, - missionId, - "worker", - step.title, - runId, - step.id, - step.step_key, - attempt?.id ?? null, - attempt?.executor_session_id ?? null, - laneId, - "closed", - 0, - json({ syntheticFixture: true, phaseKey: JSON.parse(step.metadata_json).phaseKey }), - step.created_at, - step.completed_at, - ); - } - - const msgStmt = db.prepare( - `insert into orchestrator_chat_messages( - id, project_id, mission_id, thread_id, role, content, timestamp, step_key, target_json, - visibility, delivery_state, source_session_id, attempt_id, lane_id, run_id, metadata_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const allThreads = [{ threadId: coordinatorThreadId, step: null }, ...workerThreadIds]; - for (let i = 0; i < 720; i += 1) { - const pair = allThreads[i % allThreads.length]; - const step = pair.step; - const attempt = step ? attemptRows.find((entry) => entry.step_id === step.id) : null; - const role = !step ? (i % 4 === 0 ? "user" : "orchestrator") : i % 5 === 0 ? "orchestrator" : "worker"; - msgStmt.run( - randomUUID(), - projectId, - missionId, - pair.threadId, - role, - `Synthetic ${role} message ${i}: ${step ? step.title : "mission coordination"} discussed dependencies, evidence, validation status, and next actions.`, - iso(i * 10_000), - step?.step_key ?? null, - json({ - kind: step ? "worker" : "coordinator", - runId, - stepId: step?.id ?? null, - stepKey: step?.step_key ?? null, - attemptId: attempt?.id ?? null, - }), - "full", - "delivered", - attempt?.executor_session_id ?? `synthetic-coordinator-${runId}`, - attempt?.id ?? null, - laneId, - runId, - json({ syntheticFixture: true }), - iso(i * 10_000), - ); - } - - const missionEventStmt = db.prepare( - `insert into mission_events( - id, mission_id, project_id, event_type, actor, summary, payload_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (let i = 0; i < 180; i += 1) { - missionEventStmt.run( - randomUUID(), - missionId, - projectId, - i % 6 === 0 ? "phase_completed" : "worker_update", - i % 2 === 0 ? "orchestrator" : "worker", - `Synthetic mission event ${i} for large fixture.`, - json({ syntheticFixture: true, index: i }), - iso(i * 45_000), - ); - } - - const artifactStmt = db.prepare( - `insert into mission_artifacts( - id, mission_id, project_id, artifact_type, title, description, uri, lane_id, - metadata_json, created_at, updated_at, created_by - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const orchArtifactStmt = db.prepare( - `insert into orchestrator_artifacts( - id, project_id, mission_id, run_id, step_id, attempt_id, artifact_key, kind, - value, metadata_json, declared, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const checkpointStmt = db.prepare( - `insert into orchestrator_worker_checkpoints( - id, project_id, mission_id, run_id, step_id, attempt_id, step_key, content, - file_path, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const digestStmt = db.prepare( - `insert into orchestrator_worker_digests( - id, project_id, mission_id, run_id, step_id, step_key, attempt_id, lane_id, - session_id, status, summary, files_changed_json, tests_run_json, warnings_json, - tokens_json, cost_usd, suggested_next_actions_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (let i = 0; i < 90; i += 1) { - const step = stepRows[(i * 3) % stepRows.length]; - const attempt = attemptRows.find((entry) => entry.step_id === step.id); - const phaseKey = JSON.parse(step.metadata_json).phaseKey; - artifactStmt.run( - randomUUID(), - missionId, - projectId, - i % 3 === 0 ? "test_result" : i % 3 === 1 ? "summary" : "proof", - `Synthetic artifact ${i}: ${step.title}`, - "Read-side fixture artifact for Missions UI grouping and preview stress.", - `.ade/synthetic-large/artifact-${i}.md`, - laneId, - json({ syntheticFixture: true, stepKey: step.step_key, phaseKey }), - iso(i * 80_000), - iso(i * 80_000), - step.step_key, - ); - orchArtifactStmt.run( - randomUUID(), - projectId, - missionId, - runId, - step.id, - attempt.id, - `artifact-${i}`, - i % 2 === 0 ? "summary" : "test_result", - `Synthetic artifact value ${i} for ${step.title}.`, - json({ syntheticFixture: true }), - 1, - iso(i * 80_000), - ); - checkpointStmt.run( - randomUUID(), - projectId, - missionId, - runId, - step.id, - attempt.id, - step.step_key, - `# Checkpoint ${i}\n\n${step.title} reported progress, evidence, risks, and next action handoff.`, - join(projectRoot, ".ade", "checkpoints", `synthetic-${i}.md`), - iso(i * 80_000), - iso(i * 80_000), - ); - digestStmt.run( - randomUUID(), - projectId, - missionId, - runId, - step.id, - step.step_key, - attempt.id, - laneId, - attempt.executor_session_id, - "succeeded", - `${step.title} completed synthetic slice ${i}; validation passed and no unresolved risks remain.`, - json([`src/${step.step_key}.ts`, `tests/${step.step_key}.test.ts`]), - json(["npm test -- --run synthetic", "npm run build"]), - json([]), - json({ input: 1000 + i, output: 500 + i }), - 0.002 * i, - json(["Review evidence grouping", "Continue to closeout"]), - iso(i * 80_000), - ); - } - - const interventionStmt = db.prepare( - `insert into mission_interventions( - id, mission_id, project_id, intervention_type, status, resolution_kind, title, body, - requested_action, resolution_note, lane_id, metadata_json, created_at, updated_at, resolved_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (let i = 0; i < 6; i += 1) { - interventionStmt.run( - randomUUID(), - missionId, - projectId, - i % 2 === 0 ? "manual_input" : "phase_approval", - "resolved", - "answer_provided", - `Synthetic resolved intervention ${i}`, - `Blocking question or approval ${i} was answered in this fixture.`, - "Review and approve.", - "Synthetic answer recorded.", - laneId, - json({ - syntheticFixture: true, - runId, - phaseKey: phases[i % phases.length].phaseKey, - targetPhaseKey: phases[Math.min(phases.length - 1, i + 1)].phaseKey, - }), - iso(i * 600_000), - iso(i * 600_000 + 60_000), - iso(i * 600_000 + 60_000), - ); - } -} - -try { - mkdirSync(dirname(dbPath), { recursive: true }); - db.exec("begin immediate"); - seed(); - db.exec("commit"); -} catch (error) { - try { - db.exec("rollback"); - } catch { - // Ignore rollback failure. - } - db.close(); - throw error; -} - -const counts = {}; -for (const [table, column, value] of [ - "mission_steps", - "orchestrator_runs", - "orchestrator_chat_threads", - "orchestrator_chat_messages", - "mission_artifacts", - "orchestrator_artifacts", - "orchestrator_worker_checkpoints", - "orchestrator_worker_digests", - "mission_interventions", -].map((table) => [table, "mission_id", missionId])) { - counts[table] = db.prepare(`select count(*) as count from ${table} where ${column} = ?`).get(value).count; -} -counts.missions = db.prepare("select count(*) as count from missions where id = ?").get(missionId).count; -for (const [table, column, value] of [ - "orchestrator_steps", - "orchestrator_attempts", - "orchestrator_timeline_events", - "orchestrator_runtime_events", -].map((table) => [table, "run_id", runId])) { - counts[table] = db.prepare(`select count(*) as count from ${table} where ${column} = ?`).get(value).count; -} -db.close(); - -console.log( - JSON.stringify( - { - projectRoot, - dbPath, - backupPath, - missionId, - runId, - projectId, - laneId, - counts, - }, - null, - 2, - ), -); diff --git a/scripts/seed-missions-steer-fixture.mjs b/scripts/seed-missions-steer-fixture.mjs deleted file mode 100644 index 870f06380..000000000 --- a/scripts/seed-missions-steer-fixture.mjs +++ /dev/null @@ -1,542 +0,0 @@ -#!/usr/bin/env node -import { copyFileSync, existsSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { homedir } from "node:os"; -import { randomUUID } from "node:crypto"; -import { DatabaseSync } from "node:sqlite"; - -const args = process.argv.slice(2); -const projectRoot = - args.find((arg) => arg.startsWith("--project="))?.slice("--project=".length) - ?? process.env.ADE_PERF_PASS_DIR - ?? join(homedir(), "Projects", "perf pass"); -const dbPath = join(projectRoot, ".ade", "ade.db"); -const missionId = - args.find((arg) => arg.startsWith("--mission-id="))?.slice("--mission-id=".length) - ?? "perf-steer-mission-20260520"; -const runId = - args.find((arg) => arg.startsWith("--run-id="))?.slice("--run-id=".length) - ?? "perf-steer-run-20260520"; - -if (!existsSync(dbPath)) { - console.error(`ADE DB not found: ${dbPath}`); - process.exit(2); -} - -const backupPath = `${dbPath}.backup-missions-steer-${new Date() - .toISOString() - .replace(/[-:]/g, "") - .replace(/\..+$/, "") - .replace("T", "-")}`; -copyFileSync(dbPath, backupPath); - -const db = new DatabaseSync(dbPath, { allowExtension: true }); -const crsqliteExtension = (() => { - const extensionName = - process.platform === "win32" ? "crsqlite.dll" : process.platform === "darwin" ? "crsqlite.dylib" : "crsqlite.so"; - const platformArch = - process.platform === "darwin" && process.arch === "arm64" - ? "darwin-arm64" - : process.platform === "darwin" - ? "darwin-x64" - : process.platform === "win32" - ? "win32-x64" - : "linux-x64"; - const candidate = join(process.cwd(), "apps", "desktop", "vendor", "crsqlite", platformArch, extensionName); - return existsSync(candidate) ? candidate : null; -})(); - -if (crsqliteExtension) { - db.enableLoadExtension(true); - db.loadExtension(crsqliteExtension); - db.enableLoadExtension(false); - db.prepare("select crsql_internal_sync_bit() as sync_bit").get(); -} - -const nowBase = Date.parse("2026-05-20T12:45:00.000Z"); -const iso = (offsetMs = 0) => new Date(nowBase + offsetMs).toISOString(); -const json = (value) => JSON.stringify(value); - -const project = db.prepare("select id from projects order by last_opened_at desc limit 1").get(); -if (!project?.id) throw new Error("No project row found."); -const lane = db - .prepare("select id from lanes where project_id = ? order by created_at asc limit 1") - .get(project.id); -const projectId = project.id; -const laneId = lane?.id ?? null; - -const steps = [ - { - key: "planning-baseline", - title: "Planning baseline", - detail: "Capture the initial broad launch-dashboard plan.", - status: "succeeded", - missionStatus: "succeeded", - phaseKey: "planning", - startedAt: iso(0), - completedAt: iso(180_000), - deps: [], - }, - { - key: "legacy-dashboard", - title: "Superseded broad dashboard build", - detail: "Original broad dashboard slice superseded after operator steering.", - status: "superseded", - missionStatus: "skipped", - phaseKey: "implementation", - startedAt: iso(240_000), - completedAt: iso(420_000), - deps: ["planning-baseline"], - }, - { - key: "schedule-risk-console", - title: "Schedule-risk console replacement", - detail: "Replacement slice created by the replan to focus on schedule risk evidence.", - status: "ready", - missionStatus: "pending", - phaseKey: "implementation", - startedAt: null, - completedAt: null, - deps: ["planning-baseline"], - }, - { - key: "operator-review-copy", - title: "Operator review copy", - detail: "Add concrete recovery language and scope-change notes.", - status: "blocked", - missionStatus: "blocked", - phaseKey: "experience", - startedAt: null, - completedAt: null, - deps: ["schedule-risk-console"], - }, - { - key: "focused-validation", - title: "Focused validation", - detail: "Validate the narrowed console and record risk evidence.", - status: "pending", - missionStatus: "pending", - phaseKey: "validation", - startedAt: null, - completedAt: null, - deps: ["schedule-risk-console", "operator-review-copy"], - }, -]; - -const missionStepRows = steps.map((step, index) => ({ - id: randomUUID(), - step, - index, -})); -const orchestratorStepRows = missionStepRows.map((row) => ({ - id: randomUUID(), - missionStepId: row.id, - step: row.step, - index: row.index, -})); -const stepIdByKey = new Map(orchestratorStepRows.map((row) => [row.step.key, row.id])); -const attempts = orchestratorStepRows - .filter((row) => row.step.status === "succeeded" || row.step.status === "superseded") - .map((row, index) => ({ - id: randomUUID(), - row, - status: row.step.status === "succeeded" ? "succeeded" : "failed", - attemptNumber: 1, - createdAt: iso(index * 240_000), - completedAt: row.step.completedAt ?? iso(index * 240_000 + 120_000), - })); - -const interventionId = "perf-steer-intervention-20260520"; -const coordinatorThreadId = `mission:${missionId}`; -const workerThreadId = `worker:${missionId}:schedule-risk-console`; -const directive = "Prioritize the schedule-risk console, keep the dashboard replacement narrow, and resume after I approve this revised plan."; -const replanReason = "Operator narrowed the mission from broad dashboard polish to schedule-risk proof."; - -function runStatement(sql, rows, map) { - const stmt = db.prepare(sql); - for (const row of rows) stmt.run(...map(row)); -} - -function seed() { - const missionScopedTables = [ - "orchestrator_worker_digests", - "orchestrator_worker_checkpoints", - "orchestrator_artifacts", - "orchestrator_chat_messages", - "orchestrator_chat_threads", - "orchestrator_runs", - "mission_interventions", - "mission_artifacts", - "mission_events", - "mission_steps", - ]; - for (const table of ["orchestrator_runtime_events", "orchestrator_timeline_events", "orchestrator_attempts", "orchestrator_steps"]) { - db.prepare(`delete from ${table} where run_id = ?`).run(runId); - } - for (const table of missionScopedTables) { - db.prepare(`delete from ${table} where mission_id = ?`).run(missionId); - } - db.prepare("delete from missions where id = ?").run(missionId); - db.prepare("delete from orchestrator_runs where id = ?").run(runId); - - db.prepare( - `insert into missions( - id, project_id, lane_id, mission_lane_id, result_lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, queue_claim_token, queue_claimed_at, outcome_summary, - last_error, metadata_json, created_at, updated_at, started_at, completed_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - missionId, - projectId, - laneId, - laneId, - null, - "Operator steering + replan fixture", - "Synthetic paused mission for proving the Missions steer/replan/intervention UI without launching agents.", - "intervention_required", - "high", - "local", - null, - null, - null, - "Mission paused after operator steering revised the plan; one intervention is waiting for approval.", - null, - json({ - source: "synthetic_steer_fixture", - version: 1, - steeringDirectives: [{ - missionId, - directive, - priority: "instruction", - targetStepKey: "schedule-risk-console", - appliedAt: iso(560_000), - }], - plannerPlan: { - missionSummary: { - title: "Operator steering + replan fixture", - objective: "Show a paused mission with a revised plan, active steering, and an open intervention.", - complexity: "medium", - strategy: "sequential", - parallelismCap: 1, - }, - assumptions: ["Synthetic fixture only; no workers are launched."], - risks: ["Resume is intentionally left to the operator."], - }, - }), - iso(-60_000), - iso(620_000), - iso(0), - null, - null, - ); - - db.prepare( - `insert into orchestrator_runs( - id, project_id, mission_id, status, context_profile, scheduler_state, runtime_cursor_json, - last_error, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - runId, - projectId, - missionId, - "paused", - "orchestrator_deterministic_v1", - "paused", - json({ syntheticFixture: true, waitingOn: interventionId }), - "Waiting for operator approval after replan.", - json({ - missionGoal: "Prove pause, steering, and replan UI behavior.", - autopilot: { enabled: false, executorKind: "codex", ownerId: "synthetic-steer", parallelismCap: 1 }, - aiFirst: true, - pendingReplanApproval: interventionId, - runNarrative: [ - { stepKey: "planning-baseline", summary: "Initial plan completed.", at: iso(180_000) }, - { stepKey: "schedule-risk-console", summary: "Replacement step is ready after operator steering.", at: iso(560_000) }, - ], - }), - iso(0), - iso(620_000), - iso(0), - null, - ); - - runStatement( - `insert into mission_steps( - id, mission_id, project_id, step_index, title, detail, kind, lane_id, status, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - missionStepRows, - (row) => [ - row.id, - missionId, - projectId, - row.index, - row.step.title, - row.step.detail, - row.step.phaseKey === "planning" ? "planning" : row.step.phaseKey === "validation" ? "test" : "code", - laneId, - row.step.missionStatus, - json({ syntheticFixture: true, phaseKey: row.step.phaseKey, stepKey: row.step.key }), - iso(row.index * 120_000), - row.step.completedAt ?? iso(620_000), - row.step.startedAt, - row.step.completedAt, - ], - ); - - runStatement( - `insert into orchestrator_steps( - id, run_id, project_id, mission_step_id, step_key, step_index, title, lane_id, status, - join_policy, quorum_count, dependency_step_ids_json, retry_limit, retry_count, last_attempt_id, - policy_json, metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - orchestratorStepRows, - (row) => { - const attempt = attempts.find((entry) => entry.row.id === row.id) ?? null; - return [ - row.id, - runId, - projectId, - row.missionStepId, - row.step.key, - row.index, - row.step.title, - laneId, - row.step.status, - "all_success", - null, - json(row.step.deps.map((key) => stepIdByKey.get(key)).filter(Boolean)), - 1, - row.step.status === "superseded" ? 1 : 0, - attempt?.id ?? null, - json({ claimScopes: [`${row.step.phaseKey}/**`] }), - json({ - syntheticFixture: true, - phaseKey: row.step.phaseKey, - stepType: row.step.phaseKey, - replacementFor: row.step.key === "schedule-risk-console" ? "legacy-dashboard" : null, - steeringDirectives: row.step.status === "ready" || row.step.status === "blocked" - ? [{ - directive, - priority: "instruction", - targetStepKey: "schedule-risk-console", - appliedAt: iso(560_000), - }] - : [], - }), - iso(row.index * 120_000), - row.step.completedAt ?? iso(620_000), - row.step.startedAt, - row.step.completedAt, - ]; - }, - ); - - runStatement( - `insert into orchestrator_attempts( - id, run_id, step_id, project_id, attempt_number, status, executor_kind, executor_session_id, - tracked_session_enforced, context_profile, context_snapshot_id, error_class, error_message, - retry_backoff_ms, result_envelope_json, metadata_json, created_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - attempts, - (attempt) => [ - attempt.id, - runId, - attempt.row.id, - projectId, - attempt.attemptNumber, - attempt.status, - attempt.row.step.status === "superseded" ? "codex" : "manual", - `synthetic-${attempt.row.step.key}`, - 0, - "orchestrator_deterministic_v1", - null, - attempt.status === "failed" ? "operator_replan" : "none", - attempt.status === "failed" ? "Superseded by operator scope change." : null, - 0, - json({ - schema: "ade.orchestratorAttempt.v1", - success: attempt.status === "succeeded", - summary: attempt.status === "succeeded" ? "Initial planning completed." : "Superseded by revised plan.", - warnings: [], - }), - json({ syntheticFixture: true }), - attempt.createdAt, - attempt.createdAt, - attempt.completedAt, - ], - ); - - const timelineStmt = db.prepare( - `insert into orchestrator_timeline_events( - id, project_id, run_id, step_id, attempt_id, claim_id, event_type, reason, detail_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const legacyStep = orchestratorStepRows.find((row) => row.step.key === "legacy-dashboard"); - const replacementStep = orchestratorStepRows.find((row) => row.step.key === "schedule-risk-console"); - const timelineEvents = [ - ["run_created", "Mission execution started", null, null, { syntheticFixture: true }, iso(0)], - ["step_status_changed", "succeeded", stepIdByKey.get("planning-baseline") ?? null, attempts[0]?.id ?? null, { stepKey: "planning-baseline" }, iso(180_000)], - ["coordinator_steering", directive, replacementStep?.id ?? null, null, { directive, priority: "instruction", targetStepKey: "schedule-risk-console", projectedStepCount: 2 }, iso(560_000)], - ["plan_revised", "operator_scope_change", replacementStep?.id ?? null, null, { reason: replanReason, replacedStepKeys: ["legacy-dashboard"], newStepKeys: ["schedule-risk-console"], dependencyPatchesApplied: 2 }, iso(570_000)], - ["intervention_opened", "replan_approval_required", replacementStep?.id ?? null, null, { interventionId, interventionType: "manual_input" }, iso(590_000)], - ["run_status_changed", "paused", replacementStep?.id ?? null, null, { reason: "waiting_for_operator_replan_approval" }, iso(600_000)], - ]; - for (const [eventType, reason, stepId, attemptId, detail, createdAt] of timelineEvents) { - timelineStmt.run(randomUUID(), projectId, runId, stepId, attemptId, null, eventType, reason, json(detail), createdAt); - } - - const runtimeStmt = db.prepare( - `insert into orchestrator_runtime_events( - id, project_id, run_id, step_id, attempt_id, session_id, event_type, event_key, - occurred_at, payload_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const runtimeEvents = [ - ["coordinator_steering", "steer:operator-scope-change", replacementStep?.id ?? null, null, { directive, priority: "instruction", targetStepKey: "schedule-risk-console", projectedStepCount: 2 }, iso(560_000)], - ["plan_revised", "plan:operator-scope-change", replacementStep?.id ?? null, null, { reason: replanReason, replacedStepKeys: ["legacy-dashboard"], newStepKeys: ["schedule-risk-console"], dependencyPatchesApplied: 2 }, iso(570_000)], - ["intervention_opened", `intervention_opened:${interventionId}`, replacementStep?.id ?? null, null, { interventionId, reasonCode: "scope_change_replan" }, iso(590_000)], - ]; - for (const [eventType, eventKey, stepId, attemptId, payload, occurredAt] of runtimeEvents) { - runtimeStmt.run(randomUUID(), projectId, runId, stepId, attemptId, null, eventType, eventKey, occurredAt, json(payload), occurredAt); - } - - const threadStmt = db.prepare( - `insert into orchestrator_chat_threads( - id, project_id, mission_id, thread_type, title, run_id, step_id, step_key, attempt_id, - session_id, lane_id, status, unread_count, metadata_json, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - threadStmt.run(coordinatorThreadId, projectId, missionId, "coordinator", "Orchestrator", runId, null, null, null, `synthetic-coordinator-${runId}`, laneId, "active", 0, json({ syntheticFixture: true }), iso(0), iso(620_000)); - threadStmt.run(workerThreadId, projectId, missionId, "worker", "Schedule-risk console replacement", runId, replacementStep?.id ?? null, "schedule-risk-console", null, null, laneId, "active", 0, json({ syntheticFixture: true }), iso(560_000), iso(620_000)); - - const messageStmt = db.prepare( - `insert into orchestrator_chat_messages( - id, project_id, mission_id, thread_id, role, content, timestamp, step_key, target_json, - visibility, delivery_state, source_session_id, attempt_id, lane_id, run_id, metadata_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const messages = [ - ["orchestrator", "Initial plan completed. The broad dashboard slice is ready to start.", iso(220_000), null], - ["user", directive, iso(560_000), { coordinatorChatMode: "instruction" }], - ["orchestrator", "Directive received. I revised the plan and paused for approval before resuming workers.", iso(580_000), null], - ["orchestrator", "Open intervention: approve the revised schedule-risk console plan or provide new steering.", iso(600_000), { interventionId }], - ]; - for (const [role, content, timestamp, metadata] of messages) { - messageStmt.run( - randomUUID(), - projectId, - missionId, - coordinatorThreadId, - role, - content, - timestamp, - null, - json({ kind: "coordinator", runId }), - "full", - "delivered", - role === "user" ? null : `synthetic-coordinator-${runId}`, - null, - laneId, - runId, - json({ syntheticFixture: true, ...(metadata ?? {}) }), - timestamp, - ); - } - - db.prepare( - `insert into mission_events( - id, mission_id, project_id, event_type, actor, summary, payload_json, created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - randomUUID(), - missionId, - projectId, - "mission_intervention_added", - "orchestrator", - "Mission paused for operator replan approval.", - json({ interventionId, reasonCode: "scope_change_replan" }), - iso(590_000), - ); - - db.prepare( - `insert into mission_interventions( - id, mission_id, project_id, intervention_type, status, resolution_kind, title, body, - requested_action, resolution_note, lane_id, metadata_json, created_at, updated_at, resolved_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - interventionId, - missionId, - projectId, - "manual_input", - "open", - null, - "Approve revised plan after steering", - "The operator changed scope from broad dashboard polish to a focused schedule-risk console. Review the revised plan before resuming.", - "Approve the revised plan, or type a replacement steering directive before resuming.", - null, - laneId, - json({ - syntheticFixture: true, - runId, - stepId: replacementStep?.id ?? null, - stepKey: "schedule-risk-console", - reasonCode: "scope_change_replan", - canProceedWithoutAnswer: false, - threadId: coordinatorThreadId, - }), - iso(590_000), - iso(590_000), - null, - ); - - if (!legacyStep || !replacementStep) throw new Error("Fixture steps were not initialized."); -} - -try { - mkdirSync(dirname(dbPath), { recursive: true }); - db.exec("begin immediate"); - seed(); - db.exec("commit"); -} catch (error) { - try { - db.exec("rollback"); - } catch { - // Ignore rollback failure. - } - db.close(); - throw error; -} - -const counts = {}; -for (const table of [ - "mission_steps", - "orchestrator_runs", - "orchestrator_chat_threads", - "orchestrator_chat_messages", - "mission_interventions", - "mission_events", -]) { - counts[table] = db.prepare(`select count(*) as count from ${table} where mission_id = ?`).get(missionId).count; -} -counts.missions = db.prepare("select count(*) as count from missions where id = ?").get(missionId).count; -for (const table of [ - "orchestrator_steps", - "orchestrator_attempts", - "orchestrator_timeline_events", - "orchestrator_runtime_events", -]) { - counts[table] = db.prepare(`select count(*) as count from ${table} where run_id = ?`).get(runId).count; -} -db.close(); - -console.log(JSON.stringify({ - projectRoot, - dbPath, - backupPath, - missionId, - runId, - projectId, - laneId, - counts, -}, null, 2)); diff --git a/tools/conflicts.mdx b/tools/conflicts.mdx index 01b525abe..bb1273a47 100644 --- a/tools/conflicts.mdx +++ b/tools/conflicts.mdx @@ -247,18 +247,18 @@ conflicts: ## Phase 3 Orchestrator Conflict Handling -During **Mission** execution, ADE's orchestrator monitors for conflicts introduced by worker agent actions in real time. +During **Worker run** execution, ADE's orchestrator monitors for conflicts introduced by worker agent actions in real time. When a worker agent's commit would introduce a conflict with another active lane: 1. The orchestrator detects the conflict via the continuous simulation cycle 2. The affected worker is **paused** — it receives no new instructions until the conflict is resolved -3. The conflict is surfaced in both the Mission run panel and the Conflicts tab +3. The conflict is surfaced in both the Worker run run panel and the Conflicts tab 4. After you resolve the conflict (via any resolution path), the orchestrator resumes the paused worker from where it left off This prevents the orchestrator from building further work on top of a conflicted foundation, which would compound the resolution effort. -The orchestrator does not automatically resolve conflicts on your behalf during mission execution. Resolution always requires human approval of the proposed changes — ADE will generate a proposal and pause, but it will not apply it without your confirmation. +The orchestrator does not automatically resolve conflicts on your behalf during worker run execution. Resolution always requires human approval of the proposed changes — ADE will generate a proposal and pause, but it will not apply it without your confirmation. --- @@ -293,8 +293,8 @@ This prevents the orchestrator from building further work on top of a conflicted Run integration simulations before merging and manage stacked PRs with automatic rebase. - - Learn how the orchestrator handles conflicts mid-mission and coordinates multi-lane work. + + Learn how the orchestrator handles conflicts mid-worker run and coordinates multi-lane work. Understand how lane isolation and stacking reduce the conflict surface in the first place. diff --git a/tools/history.mdx b/tools/history.mdx index de4f3fe4d..de87b0ea5 100644 --- a/tools/history.mdx +++ b/tools/history.mdx @@ -1,16 +1,16 @@ --- title: "History" -description: "A complete, searchable operations timeline for your project — every agent action, commit, mission phase, pack regeneration, and conflict event, with full replay from any checkpoint." +description: "A complete, searchable operations timeline for your project — every agent action, commit, worker run phase, pack regeneration, and conflict event, with full replay from any checkpoint." icon: "clock-rotate-left" --- ## What is the History View? -The History view is ADE's complete operations ledger — every agent action, commit, pack regeneration, mission phase, conflict, and PR event, timestamped with full operational context. It is the single place where you can answer: "What exactly happened in this project over the last two days?" +The History view is ADE's complete operations ledger — every agent action, commit, pack regeneration, worker run phase, conflict, and PR event, timestamped with full operational context. It is the single place where you can answer: "What exactly happened in this project over the last two days?" - Every agent action, git operation, lane event, and mission milestone is recorded with full metadata and context. + Every agent action, git operation, lane event, and worker run milestone is recorded with full metadata and context. Immutable snapshots at significant moments. Return to any checkpoint to see exactly what the project looked like at that instant. @@ -75,22 +75,22 @@ The History timeline records the following event categories. Each is displayed w - `lane.stacked` — lane's base was changed (reparented onto another lane) - `lane.pack_regenerated` — manual pack regeneration triggered - - Recorded at each phase transition and significant milestone in a Mission's lifecycle. + + Recorded at each phase transition and significant milestone in a Worker run's lifecycle. | Event | Description | |-------|-------------| - | `mission.created` | Mission created with initial intent | - | `mission.planning` | Mission entered the Planning phase | - | `mission.approval_requested` | Plan presented to user for approval | - | `mission.approved` | User approved the plan | - | `mission.execution_started` | Workers began executing tasks | - | `mission.validation_started` | Execution complete, validation phase begun | - | `mission.completed` | Mission finished successfully | - | `mission.failed` | Mission failed (with reason and last known state) | - | `mission.cancelled` | User cancelled the mission mid-execution | - - The detail panel for mission events shows a **mini mission summary**: the plan that was approved, the worker assignments, the validation results, and a phase timeline bar. + | `worker run.created` | Worker run created with initial intent | + | `worker run.planning` | Worker run entered the Planning phase | + | `worker run.approval_requested` | Plan presented to user for approval | + | `worker run.approved` | User approved the plan | + | `worker run.execution_started` | Workers began executing tasks | + | `worker run.validation_started` | Execution complete, validation phase begun | + | `worker run.completed` | Worker run finished successfully | + | `worker run.failed` | Worker run failed (with reason and last known state) | + | `worker run.cancelled` | User cancelled the worker run mid-execution | + + The detail panel for worker run events shows a **mini worker run summary**: the plan that was approved, the worker assignments, the validation results, and a phase timeline bar. Recorded when an Automation rule fires. @@ -132,7 +132,7 @@ The left panel shows all events in reverse chronological order (newest first). E - **Event type icon** — a distinct icon for the event category - **Short description** — a human-readable summary (e.g., "Commit to `auth-refactor`: add session expiry logic") - **Timestamp** — relative time (e.g., "3 hours ago") with full timestamp on hover -- **Lane / Mission badge** — which lane or mission the event belongs to, colored by lane +- **Lane / Worker run badge** — which lane or worker run the event belongs to, colored by lane ### Filtering the Timeline @@ -140,11 +140,11 @@ Use the filter bar at the top of the timeline to narrow the event list: | Filter | Options | |--------|---------| -| **Event type** | One or more categories (commits, sessions, missions, automations, conflicts, PRs, packs, lanes) | +| **Event type** | One or more categories (commits, sessions, worker runs, automations, conflicts, PRs, packs, lanes) | | **Lane** | One or more specific lanes | | **Date range** | Preset ranges (today, last 7 days, last 30 days) or a custom start/end | | **Agent** | Filter to events involving a specific AI model or agent role | -| **Mission** | Show only events belonging to a specific mission | +| **Worker run** | Show only events belonging to a specific worker run | ### Grouping the Timeline @@ -153,7 +153,7 @@ Use the **Group by** dropdown to change how events are organized: - **None** — flat chronological list (default) - **Day** — events grouped by calendar day with date dividers - **Session** — events grouped by the agent or terminal session that produced them -- **Mission** — events grouped under their parent mission +- **Worker run** — events grouped under their parent worker run - **Lane** — all events from a given lane are grouped together ### Searching @@ -183,8 +183,8 @@ Clicking any event in the timeline opens its full detail in the right panel. The - Session outcome tag: `completed`, `cancelled`, `error`, `timed_out` - "Replay This Session" button (see [Replay](#replay)) - - - Mission intent (the original goal) + + - Worker run intent (the original goal) - Approved plan summary - Phase timeline: planning → approval → execution → validation, with timestamps for each transition - Worker assignments: which agent ran which task in which lane @@ -219,7 +219,7 @@ Checkpoints are the "save points" of ADE's history. They are created automatical |---------|-------------| | Git commit | A checkpoint is created for every commit in every lane | | Session end | When a terminal or agent session closes, a checkpoint captures the session transcript and the lane's git state at that moment | -| Mission phase transition | A checkpoint is created at the completion of each mission phase | +| Worker run phase transition | A checkpoint is created at the completion of each worker run phase | | Manual checkpoint | You can create a manual checkpoint at any time via the "Create Checkpoint" button in the lane detail header | ### What a Checkpoint Contains @@ -227,7 +227,7 @@ Checkpoints are the "save points" of ADE's history. They are created automatical - **Git state**: The commit SHA the lane was on at this moment (or the diff if between commits) - **Session transcript**: The full session transcript if this checkpoint was triggered by a session end - **Lane Pack snapshot**: The Lane Pack content at this exact moment -- **Metadata**: Timestamp, the agent or user that triggered it, the associated lane and mission (if any) +- **Metadata**: Timestamp, the agent or user that triggered it, the associated lane and worker run (if any) - **Validation results**: Test pass/fail counts, CI status at this point (if available) Checkpoints for deleted lanes are retained in ADE's local database for 30 days. After 30 days, checkpoints for deleted lanes are purged. Checkpoints for active and archived lanes are retained indefinitely. @@ -286,11 +286,11 @@ From any checkpoint, you can branch a new lane from that point and optionally re The **Feature view** is a specialized mode in the History tab that groups all events related to a single Linear issue into one coherent narrative. -To open Feature view: use the **Group by: Mission** or **Group by: Linear Issue** options, then click any mission or issue to expand its full event tree. +To open Feature view: use the **Group by: Worker run** or **Group by: Linear Issue** options, then click any worker run or issue to expand its full event tree. A Feature view entry shows: -1. **Planning events** — the CTO's initial triage, mission creation, plan generation, and approval +1. **Planning events** — the CTO's initial triage, worker run creation, plan generation, and approval 2. **Execution events** — all commits, sessions, and worker actions across all lanes involved in the feature 3. **Conflict events** — any conflicts that arose during development, and how they were resolved 4. **PR events** — PR creation, review submissions, CI runs, and merge @@ -320,8 +320,8 @@ This is the canonical record for understanding the full lifecycle of a feature, Understand how conflict events are generated and how to use the resolution workflow. - - See how mission phase transitions create checkpoints and how replay fits into mission debugging. + + See how worker run phase transitions create checkpoints and how replay fits into worker run debugging. Learn how Lane Packs are generated and how pack events appear in the History timeline. diff --git a/tools/project-home.mdx b/tools/project-home.mdx index d8434426b..fa0f60dfe 100644 --- a/tools/project-home.mdx +++ b/tools/project-home.mdx @@ -168,7 +168,7 @@ The test suite panel shows: - **Trend**: A small sparkline showing pass rates over the last 10 runs -ADE can automatically run your test suite when an agent finishes a task in a lane. Enable this in the lane's configuration under `Settings → Agents → Post-task test run`. This is particularly useful in Mission workflows where you want automatic validation after each execution phase. +ADE can automatically run your test suite when an agent finishes a task in a lane. Enable this in the lane's configuration under `Settings → Agents → Post-task test run`. This is particularly useful in Worker run workflows where you want automatic validation after each execution phase. --- diff --git a/tools/terminals.mdx b/tools/terminals.mdx index bc8c06eea..43323ce80 100644 --- a/tools/terminals.mdx +++ b/tools/terminals.mdx @@ -65,15 +65,15 @@ ADE categorizes terminal sessions into three types. The type determines how the When you start a managed process from Project Home, ADE creates a managed session in the correct lane automatically — you do not need to manually create it. - When an AI agent (running inside a Mission, Chat, or Automation) executes terminal commands as part of its task, those commands run in an **Agent Session**. Agent sessions are: + When an AI agent (running inside a Worker run, Chat, or Automation) executes terminal commands as part of its task, those commands run in an **Agent Session**. Agent sessions are: - **Visually distinct**: The terminal panel has a blue tint and the tab label shows the agent's name (e.g., "Agent: claude-3-7-sonnet") - **Read-only for humans**: You can watch the agent's terminal in real time but cannot type into it while the agent is active. This prevents accidental interference. - - **Fully captured**: Every command and every line of output is captured in the session transcript and linked to the mission or chat turn that spawned it. - - **Attributable**: The session metadata records which agent model, which mission/chat ID, and which task step spawned this session. + - **Fully captured**: Every command and every line of output is captured in the session transcript and linked to the worker run or chat turn that spawned it. + - **Attributable**: The session metadata records which agent model, which worker run/chat ID, and which task step spawned this session. - Do not kill an agent session manually while a mission is running — this will cause the mission to fail with a terminal error. Use the mission's **Stop** button in the Mission Run panel instead, which handles cleanup gracefully. + Do not kill an agent session manually while a worker run is running — this will cause the worker run to fail with a terminal error. Use the worker run's **Stop** button in the worker detail panel instead, which handles cleanup gracefully. @@ -95,7 +95,7 @@ Every session — regardless of type — has a complete metadata record: | **Working directory** | The working directory at session start | | **Environment snapshot** | The environment variables at session start (sensitive values masked) | | **Command count** | Total number of commands run in this session | -| **Agent ID** | For Agent sessions: the agent/mission that spawned it | +| **Agent ID** | For Agent sessions: the agent/worker run that spawned it | View session metadata by clicking the **i** icon in the terminal tab header. @@ -270,7 +270,7 @@ The dialog shows the full content to be pasted. You can confirm, edit it, or can Agent sessions respect ADE's permission system. If an agent tries to run a command that requires explicit permission (e.g., running `sudo`, modifying files outside the worktree, or making network calls to non-whitelisted hosts), the command is held at a permission gate. - A prompt appears in the terminal panel and in the Mission Run panel asking you to approve or deny the command. The agent waits for your decision before proceeding. + A prompt appears in the terminal panel and in the worker detail panel asking you to approve or deny the command. The agent waits for your decision before proceeding. The Primary lane's terminal sessions run in the main repository root directory, not in a worktree. Changes made via Primary lane sessions affect the main working tree directly. If you have agents running in Worktree lanes simultaneously, their changes are invisible to the Primary lane until merged. @@ -291,7 +291,7 @@ The dialog shows the full content to be pasted. You can confirm, edit it, or can Managed process configuration — where you define the processes that create Managed Sessions. - - Understand how missions spawn and manage Agent Sessions during execution. + + Understand how worker runs spawn and manage Agent Sessions during execution. diff --git a/tools/workspace-graph.mdx b/tools/workspace-graph.mdx index dd8db16be..3649d7bd0 100644 --- a/tools/workspace-graph.mdx +++ b/tools/workspace-graph.mdx @@ -201,7 +201,7 @@ Once nodes are selected, the batch operations toolbar appears at the bottom of t | **Archive Selected** | Archive all selected lanes (with a single confirmation dialog) | | **Simulate Merges** | Run merge simulation on selected lanes only | | **Export Packs** | Export context packs for all selected lanes as a `.zip` | -| **Run Preflight** | Run mission preflight checks across all selected lanes | +| **Run Preflight** | Run worker run preflight checks across all selected lanes | --- @@ -293,7 +293,7 @@ ELK layout runs automatically when new lanes are added or when lanes are archive Manage PRs from within ADE, including review status and merge workflows. - - Launch multi-step AI missions across lanes and track them from the graph. + + Launch multi-step AI worker runs across lanes and track them from the graph. diff --git a/welcome.mdx b/welcome.mdx index 14a58a189..36159ceb3 100644 --- a/welcome.mdx +++ b/welcome.mdx @@ -26,7 +26,7 @@ Think of ADE the way you think about a CI/CD dashboard — except instead of wat Share lane templates, automation rules, and AI budgets through a committed `ade.yaml`. Each developer keeps their own API keys and preferences locally. - Orchestrate multi-step missions with planning, validation, and cost tracking. Use the CTO agent to delegate work to specialized workers automatically. + Orchestrate multi-step worker runs with planning, validation, and cost tracking. Use the CTO agent to delegate work to specialized workers automatically. @@ -62,14 +62,14 @@ ADE's sidebar mirrors this documentation. Each tab in the app has a matching sec |---------|-------------|------| | **Run** | Start your dev stack, manage processes, run tests | [Run →](/tools/project-home) | | **Lanes** | Create and manage isolated workspaces | [Lanes →](/lanes/overview) | -| **Chat** | Talk to AI agents scoped to a lane, mission, or the CTO | [Chat →](/chat/overview) | +| **Chat** | Talk to AI agents scoped to a lane, worker run, or the CTO | [Chat →](/chat/overview) | | **Work** | Terminal sessions with transcripts and checkpoints | [Terminals →](/tools/terminals) | | **Files** | Monaco editor with lane-scoped file trees and diffs | [Files →](/tools/files-editor) | | **Graph** | Visual topology of lanes, branches, PRs, and conflicts | [Graph →](/tools/workspace-graph) | | **PRs** | GitHub PR management with stacked PR support | [PRs →](/tools/pull-requests) | | **History** | Timeline of all operations, sessions, and checkpoints | [History →](/tools/history) | | **Automations** | Trigger-driven AI rules for PR review, testing, and more | [Automations →](/automations/overview) | -| **Missions** | Structured multi-step AI execution with audit trails | [Missions →](/missions/overview) | +| **Workers** | Structured multi-step AI execution with audit trails | [Workers →](/cto/workers) | | **CTO** | Always-on project agent with workers and Linear sync | [CTO →](/cto/overview) | | **Settings** | AI providers, integrations, permissions, budgets | [Settings →](/configuration/settings) | @@ -90,7 +90,7 @@ Click the **Help** icon in the top bar to open the Help menu. The first entry Download ADE, connect a provider, and open your first project. Takes about 5 minutes. - Understand lanes, packs, missions, the CTO, and how they fit together before diving in. + Understand lanes, packs, worker runs, the CTO, and how they fit together before diving in. A checklist that walks you from install to your first working agent in one session.